diff --git a/.gitignore b/.gitignore index 9543c83356..cc2acf4c14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ .DS_Store .idea **/node_modules +**/coverage/** +*.swp +*.swo +*.swn diff --git a/.types/index.d.ts b/.types/index.d.ts index 5c15596436..12eda606d6 100644 --- a/.types/index.d.ts +++ b/.types/index.d.ts @@ -8,7 +8,7 @@ type Prettify = { interface Roll20Object> { /** The unique ID of this object */ id: string; - properties: Prettify; + properties: Prettify; /** * Get an attribute of the object @@ -218,7 +218,10 @@ type CharacterProperties = { _defaulttoken: string; }; -declare type Roll20Character = Prettify>; +declare type Roll20Character = Prettify & { + /** Experimental: legacy Roll20 attributes vs Beacon sheet */ + sheetEnvironment?: "legacy" | "beacon"; +}>; // Attribute type with proper properties type AttributeProperties = { @@ -507,7 +510,7 @@ type FindObjsOptions = { * name: "target" * }, {caseInsensitive: true}); */ -declare function findObjs(attrs: Partial & { _type: T }, options?: FindObjsOptions): Roll20ObjectTypeToInstance[T][]; +declare function findObjs(attrs: Partial & { _type: T }, options?: FindObjsOptions): Roll20ObjectTypeToInstance[T][]; /** * Filters Roll20 objects by executing the callback function on each object @@ -554,7 +557,11 @@ declare function getAllObjs(): Roll20Object[]; */ declare function getAttrByName(character_id: string, attribute_name: string, value_type?: "current" | "max"): string; -type SheetItemOptions = { allowThrow?: boolean }; +type SheetItemOptions = { + allowThrow?: boolean, + createAttr?: boolean, + withWorker?: boolean +}; type SheetItemValueTYpe = "current" | "max"; /** diff --git a/ChatSetAttr/.tool-versions b/ChatSetAttr/.tool-versions new file mode 100644 index 0000000000..d7568adf6a --- /dev/null +++ b/ChatSetAttr/.tool-versions @@ -0,0 +1 @@ +nodejs 20.11.1 diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js new file mode 100644 index 0000000000..8f51e5270d --- /dev/null +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -0,0 +1,3807 @@ +// ChatSetAttr v2.0 by Jakob, GUD Team +var ChatSetAttr = (function (exports) { + 'use strict'; + + var name = "ChatSetAttr"; + var version = "2.0"; + var authors = [ + "Jakob", + "GUD Team" + ]; + var scriptJson = { + name: name, + version: version, + authors: authors}; + + // #region Style Helpers + function convertCamelToKebab(camel) { + return camel.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + } + function s(styleObject = {}) { + let style = ""; + for (const [key, value] of Object.entries(styleObject)) { + const kebabKey = convertCamelToKebab(key); + style += `${kebabKey}: ${value};`; + } + return style; + } + function escapeHtml$1(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + class SafeHtml { + html; + constructor(html) { + this.html = html; + } + } + function rawHtml(html) { + return new SafeHtml(html); + } + function renderChild(child) { + if (child instanceof SafeHtml) { + return child.html; + } + if (typeof child === "string") { + return escapeHtml$1(child); + } + return ""; + } + function h(tagName, attributes = {}, ...children) { + const attrs = Object.entries(attributes ?? {}) + .map(([key, value]) => ` ${key}="${escapeHtml$1(String(value))}"`) + .join(""); + const flattenedChildren = children.flat(10).filter(child => child != null); + const childrenContent = flattenedChildren.map(renderChild).join(""); + return new SafeHtml(`<${tagName}${attrs}>${childrenContent}`); + } + + const buttonStyleBase = { + border: "none", + borderRadius: "4px", + padding: "4px 8px", + backgroundColor: "rgba(233, 30, 162, 1)", + color: "rgba(255, 255, 255, 1)", + cursor: "pointer", + fontWeight: "500", + }; + const frameStyleBase = { + border: "1px solid rgba(59, 130, 246, 0.3)", + borderRadius: "8px", + padding: "8px", + backgroundColor: "rgba(59, 130, 246, 0.1)", + }; + const frameStyleNotice = { + border: "1px solid rgba(245, 158, 11, 0.55)", + borderRadius: "8px", + padding: "8px", + backgroundColor: "rgba(245, 158, 11, 0.18)", + }; + const frameStyleError = { + border: "1px solid rgba(239, 68, 68, 0.4)", + backgroundColor: "rgba(239, 68, 68, 0.1)", + }; + const headerStyleBase = { + fontSize: "1.5em", + marginBottom: "0.5em", + }; + + const CHAT_WRAPPER_STYLE = s(frameStyleBase); + const CHAT_HEADER_STYLE = s(headerStyleBase); + const CHAT_BODY_STYLE = s({ + fontSize: "14px", + lineHeight: "1.4", + }); + const ERROR_WRAPPER_STYLE = s({ + ...frameStyleBase, + ...frameStyleError, + }); + const ERROR_HEADER_STYLE = s(headerStyleBase); + const ERROR_BODY_STYLE = s({ + fontSize: "14px", + lineHeight: "1.4", + }); + // #region Generic Message Creation Function + function createMessage(header, messages, styles) { + return (h("div", { style: styles.wrapper }, + h("h3", { style: styles.header }, header), + h("div", { style: styles.body }, messages.map(message => h("p", null, message))))).html; + } + // #region Chat Message Function + function createChatMessage(header, messages) { + return createMessage(header, messages, { + wrapper: CHAT_WRAPPER_STYLE, + header: CHAT_HEADER_STYLE, + body: CHAT_BODY_STYLE + }); + } + // #region Error Message Function + function createErrorMessage(header, errors) { + return createMessage(header, errors, { + wrapper: ERROR_WRAPPER_STYLE, + header: ERROR_HEADER_STYLE, + body: ERROR_BODY_STYLE + }); + } + + const NOTICE_WRAPPER_STYLE = s(frameStyleNotice); + const NOTICE_HEADER_STYLE = s(headerStyleBase); + function createNoticeMessage(title, content) { + return (h("div", { style: NOTICE_WRAPPER_STYLE }, + h("div", { style: NOTICE_HEADER_STYLE }, title), + h("div", null, content))).html; + } + + const NOTIFY_WRAPPER_STYLE = s(frameStyleBase); + const NOTIFY_HEADER_STYLE = s(headerStyleBase); + function createNotifyMessage(title, content) { + return (h("div", { style: NOTIFY_WRAPPER_STYLE }, + h("div", { style: NOTIFY_HEADER_STYLE }, title), + h("div", null, rawHtml(content)))).html; + } + + function createWelcomeMessage() { + const buttonStyle = s(buttonStyleBase); + return (h("div", null, + h("p", null, "Thank you for installing ChatSetAttr."), + h("p", null, + "To get started, use the command ", + h("code", null, "!setattr-config"), + " to configure the script to your needs."), + h("p", null, + "For detailed documentation and examples, please use the ", + h("code", null, "!setattr-help"), + " command or click the button below:"), + h("p", null, + h("a", { href: "!setattr-help", style: buttonStyle }, "Create Journal Handout")))).html; + } + + const BEACON_UNSUPPORTED_NOTICE_TITLE = "Notice: Beacon Support Disabled"; + const BEACON_UNSUPPORTED_NOTICE_BODY = "Beacon character sheets are not supported on this Mod API Sandbox. " + + "Please be sure you have the correct Sandbox selected on the Mod API Scripts Page " + + "and restart the Mod API Server."; + const LONG_RUNNING_QUERY_TITLE = "Long Running Query"; + const LONG_RUNNING_QUERY_BODY = "The operation is taking a long time to execute. This may be due to a large number of " + + "targets or attributes being processed. Please be patient as the operation completes."; + function getWhisperPrefix(playerID) { + const player = getPlayerName(playerID); + return `/w "${player || "GM"}" `; + } + function normalizeCommandOutputOptions(options = {}) { + return { + mute: Boolean(options.mute), + silent: Boolean(options.silent || options.mute), + }; + } + function getPlayerName(playerID) { + const player = getObj("player", playerID); + return player?.get("_displayname") || undefined; + } + function sendMessages(playerID, header, messages, delivery, output) { + if (output?.silent) { + return; + } + const from = delivery?.from ?? "ChatSetAttr"; + const newMessage = createChatMessage(header, messages); + const chatMessage = delivery?.public + ? newMessage + : `${getWhisperPrefix(playerID)}${newMessage}`; + sendChat(from, chatMessage); + } + function sendErrors(playerID, header, errors, from, output) { + if (errors.length === 0 || output?.mute) { + return; + } + const sender = from ?? "ChatSetAttr"; + const newMessage = createErrorMessage(header, errors); + sendChat(sender, `${getWhisperPrefix(playerID)}${newMessage}`); + } + function sendDelayMessage(playerID, output) { + if (output?.silent) { + return; + } + const noticeMessage = createNoticeMessage(LONG_RUNNING_QUERY_TITLE, LONG_RUNNING_QUERY_BODY); + sendChat("ChatSetAttr", `${getWhisperPrefix(playerID)}${noticeMessage}`, undefined, { noarchive: true }); + } + function sendBeaconUnsupportedNotice() { + const message = createNoticeMessage(BEACON_UNSUPPORTED_NOTICE_TITLE, BEACON_UNSUPPORTED_NOTICE_BODY); + sendChat("ChatSetAttr", "/w gm " + message, undefined, { noarchive: true }); + } + function sendNotification(title, content, archive) { + const notifyMessage = createNotifyMessage(title, content); + sendChat("ChatSetAttr", "/w gm " + notifyMessage, undefined, { noarchive: archive }); + } + function sendWelcomeMessage() { + const welcomeMessage = createWelcomeMessage(); + sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); + } + + const CONFIG_WRAPPER_STYLE = s(frameStyleBase); + const CONFIG_HEADER_STYLE = s(headerStyleBase); + const CONFIG_TABLE_STYLE = s({ + width: "100%", + border: "none", + borderCollapse: "separate", + borderSpacing: "0 4px", + }); + const CONFIG_ROW_STYLE = s({ + marginBottom: "4px", + }); + const CONFIG_BUTTON_STYLE_ON = s({ + ...buttonStyleBase, + backgroundColor: "#16A34A", + color: "#FFFFFF", + fontWeight: "500", + }); + const CONFIG_BUTTON_STYLE_OFF = s({ + ...buttonStyleBase, + backgroundColor: "#DC2626", + color: "#FFFFFF", + fontWeight: "500", + }); + const CONFIG_CLEAR_FIX_STYLE = s({ + clear: "both", + }); + function camelToKebabCase(str) { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + } + function createConfigMessage() { + const config = getConfig(); + const configEntries = Object.entries(config); + const relevantEntries = configEntries.filter(([key]) => key !== "version" + && key !== "scriptVersion" + && key !== "globalconfigCache" + && key !== "flags" + && key !== "helpContentUpdatedAt"); + return (h("div", { style: CONFIG_WRAPPER_STYLE }, + h("div", { style: CONFIG_HEADER_STYLE }, "ChatSetAttr Configuration"), + h("div", null, + h("table", { style: CONFIG_TABLE_STYLE }, relevantEntries.map(([key, value]) => (h("tr", { style: CONFIG_ROW_STYLE }, + h("td", null, + h("strong", null, + key, + ":")), + h("td", null, + h("a", { href: `!setattr-config --${camelToKebabCase(key)}`, style: value ? CONFIG_BUTTON_STYLE_ON : CONFIG_BUTTON_STYLE_OFF }, value ? "Enabled" : "Disabled")))))), + h("div", { style: CONFIG_CLEAR_FIX_STYLE })))).html; + } + + const STATE_SCHEMA_VERSION = 4; + const GLOBAL_CONFIG_OPTIONS = [ + { + label: "Players can modify all characters", + key: "playersCanModify", + value: "playersCanModify", + }, + { + label: "Players can use --evaluate", + key: "playersCanEvaluate", + value: "playersCanEvaluate", + }, + { + label: "Trigger sheet workers when setting attributes", + key: "useWorkers", + value: "useWorkers", + }, + { + label: "Players can target party members", + key: "playersCanTargetParty", + value: "playersCanTargetParty", + }, + ]; + const DEFAULT_CONFIG = { + version: STATE_SCHEMA_VERSION, + scriptVersion: scriptJson.version, + globalconfigCache: { + lastsaved: 0, + }, + playersCanTargetParty: true, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + helpContentUpdatedAt: 0, + flags: [], + }; + function getStateSchemaVersion(raw) { + if (raw === undefined || raw === null) { + return 0; + } + if (typeof raw === "number" && Number.isFinite(raw)) { + return raw; + } + if (typeof raw === "string") { + const parsed = Number(raw); + if (Number.isFinite(parsed) && /^\d+$/.test(raw.trim())) { + return parsed; + } + return 0; + } + return 0; + } + function ensureChatSetAttrState() { + if (!state.ChatSetAttr) { + state.ChatSetAttr = {}; + } + return state.ChatSetAttr; + } + function getPersistedSchemaVersion() { + return getStateSchemaVersion(state.ChatSetAttr?.version); + } + function persistStateVersionMetadata() { + const raw = ensureChatSetAttrState(); + const schemaVersion = getStateSchemaVersion(raw.version); + if (schemaVersion > 0 && raw.version !== schemaVersion) { + raw.version = schemaVersion; + } + if (!Object.hasOwn(raw, "scriptVersion") || raw.scriptVersion !== scriptJson.version) { + raw.scriptVersion = scriptJson.version; + } + } + function syncScriptVersion() { + persistStateVersionMetadata(); + } + function parseGlobalConfigCheckbox(g, label, valueField) { + return g[label] === valueField; + } + function buildCacheSnapshot(g) { + const cache = { lastsaved: g.lastsaved ?? 0 }; + for (const option of GLOBAL_CONFIG_OPTIONS) { + cache[option.label] = `${g[option.label] ?? ""}`; + } + return cache; + } + function checkGlobalConfig() { + const g = globalconfig?.chatsetattr; + if (!g?.lastsaved) { + return []; + } + state.ChatSetAttr = state.ChatSetAttr || {}; + const cache = (state.ChatSetAttr.globalconfigCache || { lastsaved: 0 }); + if (g.lastsaved <= cache.lastsaved) { + return []; + } + const changes = []; + for (const option of GLOBAL_CONFIG_OPTIONS) { + const newRaw = `${g[option.label] ?? ""}`; + const oldRaw = `${cache[option.label] ?? ""}`; + if (newRaw === oldRaw) { + continue; + } + const newValue = parseGlobalConfigCheckbox(g, option.label, option.value); + const oldValue = getConfig()[option.key]; + if (newValue === oldValue) { + continue; + } + state.ChatSetAttr[option.key] = newValue; + changes.push(`${option.key}: ${String(oldValue)} → ${String(newValue)}`); + } + state.ChatSetAttr.globalconfigCache = buildCacheSnapshot(g); + if (changes.length > 0) { + log(`ChatSetAttr: Imported Global Config settings: ${changes.join(", ")}`); + sendNotification("ChatSetAttr Global Config", `

New settings imported from Global Config:

    ${changes.map(change => `
  • ${change}
  • `).join("")}
`, false); + } + return changes; + } + function getConfig() { + const stateConfig = state?.ChatSetAttr || {}; + return { + ...DEFAULT_CONFIG, + ...stateConfig, + }; + } + function setConfig(newConfig) { + Object.assign(ensureChatSetAttrState(), newConfig); + } + function hasFlag(flag) { + const config = getConfig(); + return config.flags.includes(flag); + } + function setFlag(flag) { + const config = getConfig(); + if (!hasFlag(flag)) { + config.flags.push(flag); + setConfig({ flags: config.flags }); + } + } + function checkConfigMessage(message) { + return message.startsWith("!setattr-config"); + } + const FLAG_MAP = { + "--players-can-modify": "playersCanModify", + "--players-can-evaluate": "playersCanEvaluate", + "--players-can-target-party": "playersCanTargetParty", + "--use-workers": "useWorkers", + }; + function handleConfigCommand(message, playerID) { + message = message.replace("!setattr-config", "").trim(); + const args = message.split(/\s+/); + const newConfig = {}; + for (const arg of args) { + const cleanArg = arg.toLowerCase(); + const flag = FLAG_MAP[cleanArg]; + if (flag !== undefined) { + newConfig[flag] = !getConfig()[flag]; + log(`Toggled config option: ${flag} to ${newConfig[flag]}`); + } + } + setConfig(newConfig); + const configMessage = createConfigMessage(); + sendChat("ChatSetAttr", `${getWhisperPrefix(playerID)}${configMessage}`, undefined, { noarchive: true }); + } + + const observers = {}; + function registerObserver(event, callback) { + if (!observers[event]) { + observers[event] = []; + } + observers[event].push(callback); + } + function notifyObservers(event, obj, prev) { + const callbacks = observers[event] || []; + callbacks.forEach(callback => { + callback(obj, prev); + }); + } + + const WRITABLE_KEYS = new Set(["current", "max"]); + function normalizeKey(key) { + return key.startsWith("_") ? key.slice(1) : key; + } + function toAttrString(value) { + if (value === undefined || value === null) { + return ""; + } + return String(value); + } + function hasSheetItemValue(value) { + return value !== null && value !== undefined && value !== ""; + } + function hasPriorValue$1(value) { + return value !== undefined && value !== null && value !== ""; + } + function toSnapshot(targetId, actualName, kind, state, id = "") { + return { + _id: id, + _type: kind, + _characterid: targetId, + name: actualName, + current: state.current, + max: state.max, + }; + } + function mergeAttributeState(targetId, actualName, priorValues, results, isDelete) { + const maxKey = `${actualName}_max`; + const priorCurrent = priorValues[targetId]?.[actualName]; + const priorMax = priorValues[targetId]?.[maxKey]; + if (isDelete) { + return { + current: toAttrString(priorCurrent), + max: toAttrString(priorMax), + priorCurrent: toAttrString(priorCurrent), + priorMax: toAttrString(priorMax), + }; + } + const newCurrent = results[targetId]?.[actualName]; + const newMax = results[targetId]?.[maxKey]; + return { + current: newCurrent !== undefined ? toAttrString(newCurrent) : toAttrString(priorCurrent), + max: newMax !== undefined ? toAttrString(newMax) : toAttrString(priorMax), + priorCurrent: toAttrString(priorCurrent), + priorMax: toAttrString(priorMax), + }; + } + function tryFindLegacyAttribute(targetId, actualName) { + return findObjs({ + _type: "attribute", + _characterid: targetId, + name: actualName, + })[0]; + } + function isLegacySheet(targetId) { + const character = getObj("character", targetId); + if (!character) { + return false; + } + return character.sheetEnvironment === "legacy" || character.sheetEnvironment === undefined; + } + function legacyAttributeForSheet(targetId, actualName) { + if (!isLegacySheet(targetId)) { + return undefined; + } + return tryFindLegacyAttribute(targetId, actualName); + } + async function resolveObserverKind(targetId, actualName) { + if (isLegacySheet(targetId)) { + return "attribute"; + } + const computed = await getSheetItem(targetId, actualName, "current"); + const computedMax = await getSheetItem(targetId, actualName, "max"); + if (hasSheetItemValue(computed) || hasSheetItemValue(computedMax)) { + return "computed"; + } + const userAttr = await getSheetItem(targetId, `user.${actualName}`, "current"); + const userMax = await getSheetItem(targetId, `user.${actualName}`, "max"); + if (hasSheetItemValue(userAttr) || hasSheetItemValue(userMax)) { + return "userAttribute"; + } + return "computed"; + } + function isNewAttributeOrUser(kind, state) { + if (kind === "computed") { + return false; + } + return state.priorCurrent === "" && state.priorMax === ""; + } + function sheetItemPath(kind, actualName) { + return kind === "userAttribute" ? `user.${actualName}` : actualName; + } + async function writeSheetItemValue(characterId, kind, actualName, key, value) { + const normalized = normalizeKey(key); + if (!WRITABLE_KEYS.has(normalized)) { + return false; + } + const type = normalized; + const path = sheetItemPath(kind, actualName); + try { + await setSheetItem(characterId, path, value, type, { + allowThrow: true, + createAttr: true, + withWorker: true, + }); + return true; + } + catch { + return false; + } + } + function createObserverAttributeObject(targetId, actualName, kind, state, id = "") { + const snapshot = toSnapshot(targetId, actualName, kind, state, id); + const obj = { + get(key) { + const normalized = normalizeKey(key); + const byKey = { + id: snapshot._id, + _id: snapshot._id, + type: snapshot._type, + _type: snapshot._type, + characterid: snapshot._characterid, + _characterid: snapshot._characterid, + name: snapshot.name, + current: snapshot.current, + max: snapshot.max, + }; + return byKey[normalized] ?? byKey[key]; + }, + set(keyOrProps, value) { + const updates = {}; + if (typeof keyOrProps === "string") { + const normalized = normalizeKey(keyOrProps); + if (WRITABLE_KEYS.has(normalized) && value !== undefined) { + updates[normalized] = value; + } + } + else { + if (keyOrProps.current !== undefined) { + updates.current = keyOrProps.current; + } + if (keyOrProps.max !== undefined) { + updates.max = keyOrProps.max; + } + } + for (const [key, nextValue] of Object.entries(updates)) { + if (nextValue === undefined) { + continue; + } + void writeSheetItemValue(targetId, kind, actualName, key, nextValue).then(ok => { + if (ok) { + snapshot[key] = nextValue; + } + }); + } + return obj; + }, + toJSON() { + return { ...snapshot }; + }, + }; + return obj; + } + function resolveObserverDestroyObj(targetId, actualName, kind) { + if (kind !== "attribute" || !isLegacySheet(targetId)) { + return undefined; + } + return tryFindLegacyAttribute(targetId, actualName); + } + function resolveObserverObj(targetId, actualName, kind, state) { + if (kind === "attribute") { + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + return legacyAttr; + } + } + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + const id = legacyAttr?.get("_id") ?? ""; + return createObserverAttributeObject(targetId, actualName, kind, state, id); + } + function resolveObserverAddObj(targetId, actualName, kind, state) { + if (kind === "attribute") { + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + return legacyAttr; + } + } + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + const id = legacyAttr?.get("_id") ?? ""; + return createObserverAttributeObject(targetId, actualName, kind, state, id); + } + async function captureDeletePriorState(targetId, actualName, kind, priorValues) { + const maxKey = `${actualName}_max`; + let priorCurrent = priorValues[targetId]?.[actualName]; + let priorMax = priorValues[targetId]?.[maxKey]; + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + if (!hasPriorValue$1(priorCurrent)) { + priorCurrent = legacyAttr.get("current"); + } + if (!hasPriorValue$1(priorMax)) { + priorMax = legacyAttr.get("max"); + } + } + else { + const userCurrent = await getSheetItem(targetId, `user.${actualName}`, "current"); + const userMax = await getSheetItem(targetId, `user.${actualName}`, "max"); + const hasUserValues = hasSheetItemValue(userCurrent) || hasSheetItemValue(userMax); + const path = hasUserValues || kind === "userAttribute" + ? `user.${actualName}` + : actualName; + if (!hasPriorValue$1(priorCurrent)) { + priorCurrent = await getSheetItem(targetId, path, "current"); + } + if (!hasPriorValue$1(priorMax)) { + priorMax = await getSheetItem(targetId, path, "max"); + } + if (!hasPriorValue$1(priorCurrent) && hasUserValues) { + priorCurrent = userCurrent; + } + if (!hasPriorValue$1(priorMax) && hasUserValues) { + priorMax = userMax; + } + } + const current = toAttrString(priorCurrent); + const max = toAttrString(priorMax); + return { + current, + max, + priorCurrent: current, + priorMax: max, + }; + } + function logicalAttributeKey(target, actualName) { + return `${target}:${actualName}`; + } + function toActualName(name) { + const isMax = name.endsWith("_max"); + return { + actualName: isMax ? name.slice(0, -4) : name, + isMax, + }; + } + + function buildSetAttributeOptions(overrides = {}) { + const { useWorkers = true } = getConfig() || {}; + return { + noCreate: overrides.noCreate ?? false, + setWithWorker: overrides.setWithWorker ?? useWorkers, + }; + } + function failureKey(target, name) { + return `${target}:${name}`; + } + function collectLogicalGroups(results) { + const groups = new Map(); + for (const target in results) { + for (const name in results[target]) { + const { actualName } = toActualName(name); + const key = logicalAttributeKey(target, actualName); + const existing = groups.get(key); + if (existing) { + existing.keys.push(name); + } + else { + groups.set(key, { target, actualName, keys: [name] }); + } + } + } + return Array.from(groups.values()); + } + function groupHasFailure(group, failed) { + return group.keys.some(name => failed.has(failureKey(group.target, name))); + } + function shouldSkipPairedMaxDelete(target, actualName, isMax, priorValues, results) { + if (!isMax) { + return false; + } + const maxKey = `${actualName}_max`; + const hasCompanionCurrent = Object.hasOwn(results[target], actualName); + if (isLegacySheet(target)) { + return hasCompanionCurrent; + } + // Beacon userAttributes are removed when current is cleared; a follow-up max delete fails. + if (hasCompanionCurrent) { + return true; + } + if (!hasPriorValue(priorValues[target]?.[maxKey])) { + return true; + } + return false; + } + function hasPriorValue(value) { + return value !== undefined && value !== null && value !== ""; + } + async function makeUpdate(operation, results, options) { + const isSetting = operation !== "delattr"; + const errors = []; + const messages = []; + const failed = []; + const failedSet = new Set(); + const { noCreate = false, priorValues = {} } = options || {}; + const setOptions = buildSetAttributeOptions({ noCreate }); + const deleteKinds = new Map(); + const deleteStates = new Map(); + const deleteObserverTargets = new Map(); + if (!isSetting) { + for (const target in results) { + for (const name in results[target]) { + const { actualName } = toActualName(name); + const groupKey = logicalAttributeKey(target, actualName); + if (!deleteKinds.has(groupKey)) { + deleteKinds.set(groupKey, await resolveObserverKind(target, actualName)); + } + if (!deleteStates.has(groupKey)) { + const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); + deleteStates.set(groupKey, await captureDeletePriorState(target, actualName, kind, priorValues)); + } + if (!deleteObserverTargets.has(groupKey)) { + const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); + deleteObserverTargets.set(groupKey, resolveObserverDestroyObj(target, actualName, kind)); + } + } + } + } + for (const target in results) { + for (const name in results[target]) { + const { actualName, isMax } = toActualName(name); + const type = isMax ? "max" : "current"; + const key = failureKey(target, name); + const newValue = results[target][name]; + if (isSetting) { + const value = newValue ?? ""; + try { + const ok = await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); + if (!ok) { + failed.push(key); + failedSet.add(key); + errors.push(`Failed to set attribute '${name}' on target '${target}'.`); + } + } + catch (error) { + failed.push(key); + failedSet.add(key); + errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); + } + } + else { + if (shouldSkipPairedMaxDelete(target, actualName, isMax, priorValues, results)) { + continue; + } + try { + const ok = await libSmartAttributes.deleteAttribute(target, actualName, type); + if (!ok) { + failed.push(key); + failedSet.add(key); + errors.push(`Failed to delete attribute '${actualName}' on target '${target}'.`); + } + } + catch (error) { + failed.push(key); + failedSet.add(key); + errors.push(`Failed to delete attribute '${actualName}' on target '${target}': ${String(error)}`); + } + } + } + } + const groups = collectLogicalGroups(results); + for (const group of groups) { + if (groupHasFailure(group, failedSet)) { + continue; + } + const groupKey = logicalAttributeKey(group.target, group.actualName); + const state = isSetting + ? mergeAttributeState(group.target, group.actualName, priorValues, results, false) + : deleteStates.get(groupKey) ?? mergeAttributeState(group.target, group.actualName, priorValues, results, true); + const kind = isSetting + ? await resolveObserverKind(group.target, group.actualName) + : deleteKinds.get(logicalAttributeKey(group.target, group.actualName)) ?? await resolveObserverKind(group.target, group.actualName); + if (isSetting) { + const prev = toSnapshot(group.target, group.actualName, kind, { + current: state.priorCurrent, + max: state.priorMax, + }); + const obj = resolveObserverObj(group.target, group.actualName, kind, state); + if (isNewAttributeOrUser(kind, state)) { + notifyObservers("add", resolveObserverAddObj(group.target, group.actualName, kind, state)); + } + notifyObservers("change", obj, prev); + } + else { + const obj = deleteObserverTargets.get(groupKey) + ?? resolveObserverObj(group.target, group.actualName, kind, state); + notifyObservers("destroy", obj); + } + } + return { errors, messages, failed }; + } + + // #region Get Attributes + async function getSingleAttribute(target, attributeName) { + const isMax = attributeName.endsWith("_max"); + const type = isMax ? "max" : "current"; + if (isMax) { + attributeName = attributeName.slice(0, -4); // remove '_max' + } + try { + const attribute = await libSmartAttributes.getAttribute(target, attributeName, type); + return attribute; + } + catch { + return undefined; + } + } + async function getAttributes(target, attributeNames) { + const attributes = {}; + if (Array.isArray(attributeNames)) { + for (const name of attributeNames) { + const cleanName = name.replace(/[^a-zA-Z0-9_]/g, ""); + attributes[cleanName] = await getSingleAttribute(target, cleanName); + } + } + else { + for (const name in attributeNames) { + const cleanName = name.replace(/[^a-zA-Z0-9_]/g, ""); + attributes[cleanName] = await getSingleAttribute(target, cleanName); + } + } + return attributes; + } + + function isBeaconSupported() { + try { + const campaign = Campaign(); + return !!campaign.computedSummary; + } + catch { + return false; + } + } + + function cleanValue(value) { + return value.trim().replace(/^['"](.*)['"]$/g, "$1"); + } + function getCharName(targetID) { + const character = getObj("character", targetID); + if (character) { + return character.get("name"); + } + return `ID: ${targetID}`; + } + + // region Command Handlers + async function setattr(changes, target, referenced = [], noCreate = false, _feedback) { + const result = {}; + const errors = []; + const request = createRequestList(referenced, changes, false); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + for (const change of changes) { + const { name, current, max } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Missing attribute ${name} not created for ${characterName}.`); + continue; + } + if (current !== undefined) { + result[name] = current; + } + if (max !== undefined) { + result[`${name}_max`] = max; + } + } + return { + result, + errors, + }; + } + async function modattr(changes, target, referenced, noCreate = false, _feedback) { + const result = {}; + const errors = []; + const currentValues = await getCurrentValues(target, referenced, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + getCharName(target); + for (const change of changes) { + const { name, current, max } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be modified.`); + continue; + } + const asNumber = Number(currentValues[name] ?? 0); + if (isNaN(asNumber)) { + errors.push(`Attribute '${name}' is not number-valued and so cannot be modified.`); + continue; + } + if (current !== undefined) { + result[name] = calculateModifiedValue(asNumber, current); + } + if (max !== undefined) { + result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); + } + } + return { + result, + errors, + }; + } + async function modbattr(changes, target, referenced, noCreate = false, _feedback) { + const result = {}; + const errors = []; + const request = createRequestList(referenced, changes, true); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + getCharName(target); + for (const change of changes) { + const { name, current, max } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be modified.`); + continue; + } + const asNumber = Number(currentValues[name]); + if (isNaN(asNumber)) { + errors.push(`Attribute '${name}' is not number-valued and so cannot be modified.`); + continue; + } + if (current !== undefined) { + result[name] = calculateModifiedValue(asNumber, current); + } + if (max !== undefined) { + result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); + } + const newMax = result[`${name}_max`] ?? currentValues[`${name}_max`]; + if (newMax !== undefined) { + const start = currentValues[name]; + result[name] = calculateBoundValue(result[name] ?? start, newMax); + } + } + return { + result, + errors, + }; + } + async function resetattr(changes, target, referenced, noCreate = false, _feedback) { + const result = {}; + const errors = []; + const request = createRequestList(referenced, changes, true); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + getCharName(target); + for (const change of changes) { + const { name } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be reset.`); + continue; + } + const maxName = `${name}_max`; + if (currentValues[maxName] !== undefined) { + const maxAsNumber = Number(currentValues[maxName]); + if (isNaN(maxAsNumber)) { + errors.push(`Attribute '${maxName}' is not number-valued and so cannot be used to reset '${name}'.`); + continue; + } + result[name] = maxAsNumber; + } + else { + result[name] = 0; + } + } + return { + result, + errors, + }; + } + async function delattr(changes, target, referenced, _, _feedback) { + const result = {}; + for (const change of changes) { + const { name } = change; + if (!name) + continue; + result[name] = undefined; + result[`${name}_max`] = undefined; + } + return { + result, + errors: [], + }; + } + const handlers = { + setattr, + modattr, + modbattr, + resetattr, + delattr, + }; + // #region Helper Functions + function createRequestList(referenced, changes, includeMax = true) { + const requestSet = new Set([...referenced]); + for (const change of changes) { + if (change.name) { + requestSet.add(change.name); + if (includeMax) { + requestSet.add(`${change.name}_max`); + } + } + } + return Array.from(requestSet); + } + function extractUndefinedAttributes(attributes) { + const names = []; + for (const name in attributes) { + if (name.endsWith("_max")) + continue; + if (attributes[name] === undefined) { + names.push(name); + } + } + return names; + } + async function getCurrentValues(target, referenced, changes) { + const queriedAttributes = new Set([...referenced]); + for (const change of changes) { + if (change.name) { + queriedAttributes.add(change.name); + queriedAttributes.add(`${change.name}_max`); + } + } + const attributes = await getAttributes(target, Array.from(queriedAttributes)); + return attributes; + } + function calculateModifiedValue(baseValue, modification) { + const operator = getOperator(modification); + baseValue = Number(baseValue); + if (operator) { + modification = Number(String(modification).substring(1)); + } + else { + modification = Number(modification); + } + if (isNaN(baseValue)) + baseValue = 0; + if (isNaN(modification)) + modification = 0; + return applyCalculation(baseValue, modification, operator); + } + function getOperator(value) { + if (typeof value === "string") { + const match = value.match(/^([+\-*/])/); + if (match) { + return match[1]; + } + } + return; + } + function applyCalculation(baseValue, modification, operator = "+") { + modification = Number(modification); + switch (operator) { + case "+": + return baseValue + modification; + case "-": + return baseValue - modification; + case "*": + return baseValue * modification; + case "/": + return modification !== 0 ? baseValue / modification : baseValue; + default: + return baseValue + modification; + } + } + function calculateBoundValue(currentValue, maxValue) { + currentValue = Number(currentValue); + maxValue = Number(maxValue); + if (isNaN(currentValue)) + currentValue = 0; + if (isNaN(maxValue)) + return currentValue; + return Math.max(Math.min(currentValue, maxValue), 0); + } + + function formatFeedbackValue(value) { + if (value === undefined || value === null || value === "") { + return "(empty)"; + } + return String(value); + } + function formatAttributePart(name, result) { + const hasCurrent = Object.hasOwn(result, name); + const maxKey = `${name}_max`; + const hasMax = Object.hasOwn(result, maxKey); + if (!hasCurrent && !hasMax) { + return null; + } + if (hasCurrent && hasMax) { + return `${name} to ${formatFeedbackValue(result[name])} / ${formatFeedbackValue(result[maxKey])}`; + } + if (hasCurrent) { + return `${name} to ${formatFeedbackValue(result[name])}`; + } + return `${name} to ${formatFeedbackValue(result[maxKey])} (max)`; + } + function formatSettingFeedback(characterName, changes, result) { + const parts = []; + for (const change of changes) { + if (!change.name) + continue; + const part = formatAttributePart(change.name, result); + if (part) { + parts.push(part); + } + } + if (parts.length === 0) { + return null; + } + return `Setting ${parts.join(", ")} for character ${characterName}.`; + } + function formatDeleteFeedback(characterName, changes, result) { + const names = []; + for (const change of changes) { + if (!change.name) + continue; + if (Object.hasOwn(result, change.name)) { + names.push(change.name); + } + } + if (names.length === 0) { + return null; + } + return `Deleting attribute(s) ${names.join(", ")} for character ${characterName}.`; + } + function createFeedbackMessage(characterName, feedback, startingValues, targetValues) { + let message = feedback?.content ?? ""; + // _NAMEJ_: will insert the attribute name. + // _TCURJ_: will insert what you are changing the current value to (or changing by, if you're using --mod or --modb). + // _TMAXJ_: will insert what you are changing the maximum value to (or changing by, if you're using --mod or --modb). + // _CHARNAME_: will insert the character name. + // _CURJ_: will insert the final current value of the attribute, for this character. + // _MAXJ_: will insert the final maximum value of the attribute, for this character. + const targetValueKeys = getChangedAttributeNames(targetValues); + message = message.replace("_CHARNAME_", characterName); + message = message.replace(/_(NAME|TCUR|TMAX|CUR|MAX)(\d+)_/g, (_, key, num) => { + const index = parseInt(num, 10); + const attributeName = targetValueKeys[index]; + if (!attributeName) + return ""; + const sheetCurrent = startingValues[attributeName]; + const sheetMax = startingValues[`${attributeName}_max`]; + const resultCurrent = targetValues[attributeName]; + const resultMax = targetValues[`${attributeName}_max`]; + switch (key) { + case "NAME": + return attributeName; + case "TCUR": + return sheetCurrent !== undefined ? `${sheetCurrent}` : ""; + case "TMAX": + return sheetMax !== undefined ? `${sheetMax}` : ""; + case "CUR": { + const value = resultCurrent ?? sheetCurrent; + return value !== undefined ? `${value}` : ""; + } + case "MAX": { + const value = resultMax ?? sheetMax; + return value !== undefined ? `${value}` : ""; + } + default: + return ""; + } + }); + return message; + } + function getChangedAttributeNames(targetValues) { + const seen = new Set(); + const names = []; + for (const key of Object.keys(targetValues)) { + const name = key.endsWith("_max") ? key.slice(0, -4) : key; + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + } + return names; + } + + var $schema = "./content.schema.json"; + var title = "ChatSetAttr"; + var introduction = "ChatSetAttr is a Roll20 Mod API script that allows users to create, modify, or delete character sheet attributes through chat commands macros. Whether you need to update a single character attribute or make bulk changes across multiple characters, ChatSetAttr provides flexible options to streamline your game management."; + var sections = [ + { + id: "basic-usage", + title: "Basic Usage", + blocks: [ + { + type: "paragraph", + text: "The script provides several command formats:" + }, + { + type: "unorderedList", + items: [ + "`!setattr [--options]` - Create or modify attributes", + "`!modattr [--options]` - Shortcut for `!setattr --mod` (adds to existing values)", + "`!modbattr [--options]` - Shortcut for `!setattr --modb` (adds to values with bounds)", + "`!resetattr [--options]` - Shortcut for `!setattr --reset` (resets to max values)", + "`!delattr [--options]` - Delete attributes" + ] + }, + { + type: "paragraph", + text: "Each command requires a target selection option and one or more attributes to modify." + }, + { + type: "paragraph", + text: "**Basic structure:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2" + ] + } + ] + }, + { + id: "available-commands", + title: "Available Commands", + subsections: [ + { + id: "setattr", + title: "!setattr", + blocks: [ + { + type: "paragraph", + text: "Creates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless `--nocreate` is specified)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|25|50 --hp_temp|8" + ] + }, + { + type: "paragraph", + text: "This would set `hp` to 25, `hp_max` to 50, `hp_temp` to 8." + } + ] + }, + { + id: "modattr", + title: "!modattr", + blocks: [ + { + type: "paragraph", + text: "Adds to existing attribute values (works only with numeric values). Shorthand for `!setattr --mod`." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!modattr --sel --hp_temp|-5 --hp|6" + ] + }, + { + type: "paragraph", + text: "This subtracts 5 from `hp_temp` and adds 6 to `hp`." + } + ] + }, + { + id: "modbattr", + title: "!modbattr", + blocks: [ + { + type: "paragraph", + text: "Adds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for `!setattr --modb`." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!modbattr --sel --hp_temp|-5 --hp|25" + ] + }, + { + type: "paragraph", + text: "This subtracts 5 from `hp_temp` but won't reduce it below 0 and increase `hp` by 25, but won't increase it above `mp_xp`." + } + ] + }, + { + id: "resetattr", + title: "!resetattr", + blocks: [ + { + type: "paragraph", + text: "Resets attributes to their maximum value. Shorthand for `!setattr --reset`." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!resetattr --sel --hp" + ] + }, + { + type: "paragraph", + text: "This resets `hp` to its maximum value." + } + ] + }, + { + id: "delattr", + title: "!delattr", + blocks: [ + { + type: "paragraph", + text: "Deletes the specified attributes." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!delattr --sel --hp --hp_temp" + ] + }, + { + type: "paragraph", + text: "This removes the `hp` and `hp_temp` attributes." + } + ] + } + ] + }, + { + id: "beacon-computed-values", + title: "Beacon Computed Values", + blocks: [ + { + type: "paragraph", + text: "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try." + }, + { + type: "paragraph", + text: "Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message." + }, + { + type: "paragraph", + text: "For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with `user.` like `user.spellpoints`. They function like attributes and can be created, removed, set, reset, and modified as desired." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --spellpoints|18" + ] + }, + { + type: "paragraph", + text: "This will create the `user.spellpoints` User Attribute, which can be referenced as either `@{selected|user.spellpoints}` or `@{selected|spellpoints}` and operates like an attribute." + } + ] + }, + { + id: "target-selection", + title: "Target Selection", + blocks: [ + { + type: "paragraph", + text: "One of these options must be specified to determine which characters will be affected:" + } + ], + subsections: [ + { + id: "all", + title: "--all", + blocks: [ + { + type: "paragraph", + text: "Affects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!resetattr --all --hp" + ] + } + ] + }, + { + id: "allgm", + title: "--allgm", + blocks: [ + { + type: "paragraph", + text: "Affects all characters without player controllers (typically NPCs). **GM only**." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --allgm --reset --hp" + ] + } + ] + }, + { + id: "allplayers", + title: "--allplayers", + blocks: [ + { + type: "paragraph", + text: "Affects all characters with player controllers (typically PCs)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --allplayers --mod --hp|-15" + ] + } + ] + }, + { + id: "charid", + title: "--charid", + blocks: [ + { + type: "paragraph", + text: "Affects characters with the specified character IDs. Non-GM players can only affect characters they control. Multiple IDs must be separated by a comma." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --charid , --hp|150" + ] + } + ] + }, + { + id: "name", + title: "--name", + blocks: [ + { + type: "paragraph", + text: "Affects characters with the specified names. Non-GM players can only affect characters they control. Multiple character names must be separated by a comma." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --name Gandalf, Frodo Baggins --party|\"Fellowship of the Ring\"" + ] + } + ] + }, + { + id: "sel", + title: "--sel", + blocks: [ + { + type: "paragraph", + text: "Affects characters represented by currently selected tokens." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|25 --hp_temp|8" + ] + } + ] + }, + { + id: "sel-party", + title: "--sel-party", + blocks: [ + { + type: "paragraph", + text: "Affects only party characters represented by currently selected tokens (characters with `inParty` set to true)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-party --inspiration|1" + ] + } + ] + }, + { + id: "sel-noparty", + title: "--sel-noparty", + blocks: [ + { + type: "paragraph", + text: "Affects only non-party characters represented by currently selected tokens (characters with `inParty` set to false or not set)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-noparty --npc_status|\"Hostile\"" + ] + } + ] + }, + { + id: "party", + title: "--party", + blocks: [ + { + type: "paragraph", + text: "Affects all characters marked as party members (characters with `inParty` set to true). **GM only by default**, but can be enabled for players with configuration." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --party --rest_complete|1" + ] + } + ] + } + ] + }, + { + id: "attribute-syntax", + title: "Attribute Syntax", + blocks: [ + { + type: "paragraph", + text: "The syntax for specifying attributes is:" + }, + { + type: "codeBlock", + lines: [ + "--attributeName|currentValue|maxValue" + ] + }, + { + type: "unorderedList", + items: [ + "`attributeName` is the name of the attribute to modify", + "`currentValue` is the value to set (optional for some commands)", + "`maxValue` is the maximum value to set (optional)" + ] + } + ], + subsections: [ + { + id: "examples", + title: "Examples:", + blocks: [ + { + type: "orderedList", + items: [ + { + text: "Set current value only:", + codeBlock: { + lines: [ + "--strength|15" + ] + } + }, + { + text: "Set both current and maximum values:", + codeBlock: { + lines: [ + "--hp|27|35" + ] + } + }, + { + text: "Set only the maximum value (leave current unchanged):", + codeBlock: { + lines: [ + "--hp||50" + ] + } + }, + { + text: "Create empty attribute or set to empty:", + codeBlock: { + lines: [ + "--notes|" + ] + } + }, + { + text: "Use `#` instead of `|` (useful in roll queries):", + codeBlock: { + lines: [ + "--strength#15" + ] + } + } + ] + } + ] + } + ] + }, + { + id: "multiline-commands", + title: "Multiline Commands", + blocks: [ + { + type: "paragraph", + text: "Long commands can be spread across multiple lines for readability by wrapping the entire body of options in double curly braces `{{ }}`. When Roll20 sends a multiline message to the API, ChatSetAttr strips the line breaks and the surrounding braces before parsing, so a `{{ }}`-wrapped command behaves identically to the same command written on a single line." + }, + { + type: "paragraph", + text: "**Single-line form:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --replace --repeating_classfeature_-create_name|Some Feature --repeating_classfeature_-create_content|Some long content to set in the body of this class feature. --repeating_classfeature_-create_content_toggle|1" + ] + }, + { + type: "paragraph", + text: "**Equivalent multiline form:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr {{", + "--sel", + "--replace", + "--repeating_classfeature_-create_name|Some Feature", + "--repeating_classfeature_-create_content|Some long content to set in the body of this class feature.", + "--repeating_classfeature_-create_content_toggle|1", + "}}" + ] + }, + { + type: "paragraph", + text: "Escaped braces `\\{` and `\\}` inside the body are unescaped to literal `{` and `}` after the wrapper is removed, so you can still include braces in attribute values." + }, + { + type: "note", + text: "**Whitespace limitation:** The closing `}}` must be preceded by whitespace (a space or a line break) for the block to be unwrapped. Placing each option on its own line satisfies this automatically, but writing the last value directly against the closing braces (for example `...value}}`) will not be recognized as a multiline command. Keep the closing `}}` on its own line or leave a space before it.", + emphasis: true + } + ] + }, + { + id: "modifier-options", + title: "Modifier Options", + blocks: [ + { + type: "paragraph", + text: "These options change how attributes are processed:" + } + ], + subsections: [ + { + id: "mod", + title: "--mod", + blocks: [ + { + type: "paragraph", + text: "See `!modattr` command." + } + ] + }, + { + id: "modb", + title: "--modb", + blocks: [ + { + type: "paragraph", + text: "See `!modbattr` command." + } + ] + }, + { + id: "reset", + title: "--reset", + blocks: [ + { + type: "paragraph", + text: "See `!resetattr` command." + } + ] + }, + { + id: "nocreate", + title: "--nocreate", + blocks: [ + { + type: "paragraph", + text: "Prevents creation of new attributes, only updates existing ones." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --nocreate --perception|20 --hp|15" + ] + }, + { + type: "paragraph", + text: "This will only update `perception` or `hp` if it already exists." + } + ] + }, + { + id: "evaluate", + title: "--evaluate", + blocks: [ + { + type: "paragraph", + text: "Evaluates JavaScript expressions in attribute values. **GM only by default**." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --evaluate --hp|2 * 3" + ] + }, + { + type: "paragraph", + text: "This will set the `hp` attribute to 6." + } + ] + }, + { + id: "replace", + title: "--replace", + blocks: [ + { + type: "paragraph", + text: "Replaces special characters to prevent Roll20 from evaluating them:" + }, + { + type: "unorderedList", + items: [ + "< becomes [", + "> becomes ]", + "~ becomes -", + "; becomes ?", + "` becomes @" + ] + }, + { + type: "paragraph", + text: "Also supports \\lbrak, \\rbrak, \\n, \\at, and \\ques for [, ], newline, @, and ?." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"" + ] + }, + { + type: "paragraph", + text: "This stores \"Roll [[1d6]] to succeed\" without evaluating the roll." + } + ] + } + ] + }, + { + id: "output-control-options", + title: "Output Control Options", + blocks: [ + { + type: "paragraph", + text: "These options control the feedback messages generated by the script:" + } + ], + subsections: [ + { + id: "silent", + title: "--silent", + blocks: [ + { + type: "paragraph", + text: "Suppresses normal output messages (error messages will still appear)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --silent --stealth|20" + ] + } + ] + }, + { + id: "mute", + title: "--mute", + blocks: [ + { + type: "paragraph", + text: "Suppresses all output messages, including errors." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --mute --nocreate --new_value|42" + ] + } + ] + }, + { + id: "fb-public", + title: "--fb-public", + blocks: [ + { + type: "paragraph", + text: "Sends output publicly to the chat instead of whispering to the command sender." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-public --hp|25|25 --status|\"Healed\"" + ] + } + ] + }, + { + id: "fb-from", + title: "--fb-from ", + blocks: [ + { + type: "paragraph", + text: "Changes the name of the sender for output messages (default is \"ChatSetAttr\")." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-from \"Healing Potion\" --hp|25" + ] + } + ] + }, + { + id: "fb-header", + title: "--fb-header ", + blocks: [ + { + type: "paragraph", + text: "Customizes the header of the output message." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --evaluate --fb-header \"Combat Effects Applied\" --status|\"Poisoned\" --hp|%hp%-5" + ] + } + ] + }, + { + id: "fb-content", + title: "--fb-content ", + blocks: [ + { + type: "paragraph", + text: "Customizes the content of the output message." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-content \"Increasing Hitpoints\" --hp|10" + ] + } + ] + }, + { + id: "special-placeholders", + title: "Special Placeholders", + blocks: [ + { + type: "paragraph", + text: "For use in `--fb-header` and `--fb-content`:" + }, + { + type: "unorderedList", + items: [ + "`_NAMEJ_` - Name of the Jth attribute being changed", + "`_TCURJ_` - Target current value of the Jth attribute", + "`_TMAXJ_` - Target maximum value of the Jth attribute" + ] + }, + { + type: "paragraph", + text: "For use in `--fb-content` only:" + }, + { + type: "unorderedList", + items: [ + "`_CHARNAME_` - Name of the character", + "`_CURJ_` - Final current value of the Jth attribute", + "`_MAXJ_` - Final maximum value of the Jth attribute" + ] + }, + { + type: "paragraph", + text: "**Important:** The Jth index starts with 0 at the first item." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-header \"Healing Effects\" --fb-content \"_CHARNAME_ healed by _CUR0_ hitpoints --hp|10" + ] + } + ] + } + ] + }, + { + id: "inline-roll-integration", + title: "Inline Roll Integration", + blocks: [ + { + type: "paragraph", + text: "ChatSetAttr can be used within roll templates or combined with inline rolls:" + } + ], + subsections: [ + { + id: "within-roll-templates", + title: "Within Roll Templates", + blocks: [ + { + type: "paragraph", + text: "Place the command between roll template properties and end it with `!!!`:" + }, + { + type: "codeBlock", + lines: [ + "&{template:default} {{name=Fireball Damage}} !setattr --mod --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}" + ] + } + ] + }, + { + id: "using-inline-rolls-in-values", + title: "Using Inline Rolls in Values", + blocks: [ + { + type: "paragraph", + text: "Inline rolls can be used for attribute values:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|[[2d6+5]]" + ] + } + ] + }, + { + id: "roll-queries", + title: "Roll Queries", + blocks: [ + { + type: "paragraph", + text: "Roll queries can determine attribute values:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|?{Set strength to what value?|100}" + ] + } + ] + } + ] + }, + { + id: "repeating-section-support", + title: "Repeating Section Support", + blocks: [ + { + type: "paragraph", + text: "ChatSetAttr supports working with repeating sections:" + } + ], + subsections: [ + { + id: "creating-new-repeating-items", + title: "Creating New Repeating Items", + blocks: [ + { + type: "paragraph", + text: "Use `CREATE` to create a new row in a repeating section:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_CREATE_itemname|\"Magic Sword\" --repeating_inventory_CREATE_itemweight|2" + ] + } + ] + }, + { + id: "modifying-existing-repeating-items", + title: "Modifying Existing Repeating Items", + blocks: [ + { + type: "paragraph", + text: "Access by row ID:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_ID_itemname|\"Enchanted Magic Sword\"" + ] + }, + { + type: "paragraph", + text: "Access by index (starts at 0):" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_$0_itemname|\"First Item\"" + ] + } + ] + }, + { + id: "deleting-repeating-rows", + title: "Deleting Repeating Rows", + blocks: [ + { + type: "paragraph", + text: "Delete by row ID:" + }, + { + type: "codeBlock", + lines: [ + "!delattr --sel --repeating_inventory_ID" + ] + }, + { + type: "paragraph", + text: "Delete by index:" + }, + { + type: "codeBlock", + lines: [ + "!delattr --sel --repeating_inventory_$0" + ] + }, + { + type: "note", + text: "repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.", + emphasis: true + } + ] + } + ] + }, + { + id: "special-value-expressions", + title: "Special Value Expressions", + subsections: [ + { + id: "attribute-references", + title: "Attribute References", + blocks: [ + { + type: "paragraph", + text: "Reference other attribute values using `%attribute_name%`:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --evaluate --temp_hp|%hp% / 2" + ] + } + ] + }, + { + id: "resetting-to-maximum", + title: "Resetting to Maximum", + blocks: [ + { + type: "paragraph", + text: "Reset an attribute to its maximum value:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|%hp_max%" + ] + } + ] + } + ] + }, + { + id: "global-configuration", + title: "Global Configuration", + blocks: [ + { + type: "paragraph", + text: "The script has four global configuration options that can be toggled with `!setattr-config`:" + } + ], + subsections: [ + { + id: "players-can-modify", + title: "--players-can-modify", + blocks: [ + { + type: "paragraph", + text: "Allows players to modify attributes on characters they don't control." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --players-can-modify" + ] + } + ] + }, + { + id: "players-can-evaluate", + title: "--players-can-evaluate", + blocks: [ + { + type: "paragraph", + text: "Allows players to use the `--evaluate` option." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --players-can-evaluate" + ] + } + ] + }, + { + id: "players-can-target-party", + title: "--players-can-target-party", + blocks: [ + { + type: "paragraph", + text: "Allows players to use the `--party` target option. **GM only by default**." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --players-can-target-party" + ] + } + ] + }, + { + id: "use-workers", + title: "--use-workers", + blocks: [ + { + type: "paragraph", + text: "Toggles whether the script triggers sheet workers when setting attributes." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --use-workers" + ] + } + ] + } + ] + }, + { + id: "complete-examples", + title: "Complete Examples", + subsections: [ + { + id: "basic-combat-example", + title: "Basic Combat Example", + blocks: [ + { + type: "paragraph", + text: "Reduce a character's HP and status after taking damage:" + }, + { + type: "codeBlock", + lines: [ + "!modattr --sel --evaluate --hp|-15 --fb-header \"Combat Result\" --fb-content \"_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!\"" + ] + } + ] + }, + { + id: "leveling-up-a-character", + title: "Leveling Up a Character", + blocks: [ + { + type: "paragraph", + text: "Update multiple stats when a character gains a level:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from \"Level Up\" --fb-header \"Character Advanced\" --fb-public" + ] + } + ] + }, + { + id: "create-new-item-in-inventory", + title: "Create New Item in Inventory", + blocks: [ + { + type: "paragraph", + text: "Add a new item to a character's inventory:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_-CREATE_itemname|\"Healing Potion\" --repeating_inventory_-CREATE_itemcount|3 --repeating_inventory_-CREATE_itemweight|0.5 --repeating_inventory_-CREATE_itemcontent|\"Restores 2d8+2 hit points when consumed\"" + ] + } + ] + }, + { + id: "apply-status-effects-during-combat", + title: "Apply Status Effects During Combat", + blocks: [ + { + type: "paragraph", + text: "Apply a debuff to selected enemies in the middle of combat:" + }, + { + type: "codeBlock", + lines: [ + "&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|\"Restrained\"!!! {{duration=1d4 rounds}}" + ] + } + ] + }, + { + id: "party-management-examples", + title: "Party Management Examples", + blocks: [ + { + type: "paragraph", + text: "Give inspiration to all party members after a great roleplay moment:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --party --inspiration|1 --fb-public --fb-header \"Inspiration Awarded\" --fb-content \"All party members receive inspiration for excellent roleplay!\"" + ] + }, + { + type: "paragraph", + text: "Apply a long rest to only party characters among selected tokens:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header \"Long Rest Complete\"" + ] + }, + { + type: "paragraph", + text: "Set hostile status for non-party characters among selected tokens:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-noparty --attitude|\"Hostile\" --fb-from \"DM\" --fb-content \"Enemies are now hostile!\"" + ] + } + ] + } + ] + }, + { + id: "for-developers", + title: "For Developers", + subsections: [ + { + id: "registering-observers", + title: "Registering Observers", + blocks: [ + { + type: "paragraph", + text: "If you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:" + }, + { + type: "codeBlock", + lines: [ + "ChatSetAttr.registerObserver(event, observer);" + ] + }, + { + type: "paragraph", + text: "Where `event` is one of:" + }, + { + type: "unorderedList", + items: [ + "`\"add\"` - Called when attributes are created", + "`\"change\"` - Called when attributes are modified", + "`\"destroy\"` - Called when attributes are deleted" + ] + }, + { + type: "paragraph", + text: "And `observer` is an event handler function similar to Roll20's built-in event handlers." + }, + { + type: "paragraph", + text: "This allows your scripts to react to changes made by ChatSetAttr the same way they would react to changes made directly by Roll20's interface." + } + ] + } + ] + } + ]; + var helpContent = { + $schema: $schema, + title: title, + introduction: introduction, + sections: sections + }; + + function loadHelpDocument() { + return helpContent; + } + + const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`)/g; + function renderInlineHtml(text) { + const parts = []; + let lastIndex = 0; + let match; + INLINE_PATTERN.lastIndex = 0; + while ((match = INLINE_PATTERN.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(escapeHtml(text.slice(lastIndex, match.index))); + } + const token = match[0]; + if (token.startsWith("**")) { + parts.push(`${escapeHtml(token.slice(2, -2))}`); + } + else { + parts.push(`${escapeHtml(token.slice(1, -1))}`); + } + lastIndex = match.index + token.length; + } + if (lastIndex < text.length) { + parts.push(escapeHtml(text.slice(lastIndex))); + } + return new SafeHtml(parts.join("")); + } + function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + function joinCodeLines(lines) { + return lines.join("\n"); + } + + function concatHtml(...parts) { + return new SafeHtml(parts.map(part => part.html).join("")); + } + function renderBlocks(blocks) { + if (!blocks) + return []; + const parts = []; + for (const block of blocks) { + switch (block.type) { + case "paragraph": + parts.push(h("p", {}, renderInlineHtml(block.text))); + break; + case "codeBlock": + parts.push(h("pre", {}, h("code", {}, joinCodeLines(block.lines)))); + break; + case "unorderedList": + parts.push(h("ul", {}, ...block.items.map(item => h("li", {}, renderInlineHtml(item))))); + break; + case "orderedList": + parts.push(h("ol", {}, ...block.items.map(item => { + const children = [renderInlineHtml(item.text)]; + if (item.codeBlock) { + children.push(h("pre", {}, h("code", {}, joinCodeLines(item.codeBlock.lines)))); + } + return h("li", {}, ...children); + }))); + break; + case "note": + parts.push(block.emphasis + ? h("p", {}, h("em", {}, h("strong", {}, "Note:"), " ", renderInlineHtml(block.text))) + : h("p", {}, renderInlineHtml(block.text))); + break; + } + } + return parts; + } + function renderSubsection(subsection) { + return concatHtml(h("h3", {}, subsection.title), ...renderBlocks(subsection.blocks)); + } + function renderSection(section) { + return concatHtml(h("h2", { id: section.id }, section.title), ...renderBlocks(section.blocks), ...(section.subsections?.map(renderSubsection) ?? [])); + } + function renderTableOfContents(doc, handoutID) { + return h("ol", {}, ...doc.sections.map(section => h("li", {}, h("a", { + href: `http://journal.roll20.net/handout/${handoutID}/#${section.title.replace(/\s+/g, "%20")}`, + }, section.title)))); + } + function renderHelpHtml(doc, handoutID) { + return concatHtml(h("h1", {}, doc.title), h("p", {}, doc.introduction), h("h2", {}, "Table of Contents"), renderTableOfContents(doc, handoutID), ...doc.sections.map(section => renderSection(section))).html; + } + + function createHelpHandout(handoutID) { + return renderHelpHtml(loadHelpDocument(), handoutID); + } + + var updatedAt = 1783723898022; + var contentRevision = { + updatedAt: updatedAt + }; + + const revision = contentRevision; + function getBundledHelpContentUpdatedAt() { + return revision.updatedAt; + } + + const HELP_COMMAND = "!setattr-help"; + const HELP_HANDOUT_NAME = "ChatSetAttr Help"; + function checkHelpMessage(msg) { + return msg.trim().toLowerCase().startsWith(HELP_COMMAND); + } + function findHelpHandout() { + return findObjs({ + _type: "handout", + name: HELP_HANDOUT_NAME, + })[0]; + } + function applyHelpContentToHandout(handout) { + const helpContent = createHelpHandout(handout.id); + const bundledAt = getBundledHelpContentUpdatedAt(); + handout.set({ + inplayerjournals: "all", + notes: helpContent, + }); + setConfig({ helpContentUpdatedAt: bundledAt }); + } + function handleHelpCommand() { + let handout = findHelpHandout(); + if (!handout) { + handout = createObj("handout", { + name: HELP_HANDOUT_NAME, + }); + } + applyHelpContentToHandout(handout); + } + function syncHelpHandoutOnStartup() { + const handout = findHelpHandout(); + if (!handout) { + return; + } + const bundledAt = getBundledHelpContentUpdatedAt(); + const stateAt = getConfig().helpContentUpdatedAt; + if (stateAt >= bundledAt) { + return; + } + applyHelpContentToHandout(handout); + } + + function inlineRollValue(roll) { + const tableItems = roll.results.rolls.reduce((names, subRoll) => { + const tableSubRoll = subRoll; + if (!Object.prototype.hasOwnProperty.call(tableSubRoll, "table")) { + return names; + } + const subNames = (tableSubRoll.results ?? []) + .map(result => result.tableItem?.name ?? "") + .filter(Boolean); + if (subNames.length) { + names.push(subNames.join(", ")); + } + return names; + }, []); + const tableText = tableItems.filter(Boolean).join(", "); + return (tableText.length && tableText) || roll.results.total || 0; + } + function normalizeTemplateRollProperties(content) { + return content + .replace(/\{\{[^}[\]]+=\$?\[\[(\d+)\]\].*?\}\}/g, (_, index) => `$[[${index}]]`) + .replace(/\{\{[^}=]+=([^}]+)\}\}/g, (_, value) => value.trim()); + } + function processInlinerolls(msg) { + if (!msg.inlinerolls?.length) { + return msg.content; + } + const values = msg.inlinerolls.map(roll => String(inlineRollValue(roll))); + return values.reduce((content, value, index) => content.replace(`$[[${index}]]`, value), msg.content); + } + + // #region Commands + const COMMAND_TYPE = [ + "setattr", + "modattr", + "modbattr", + "resetattr", + "delattr" + ]; + function isCommand(command) { + return COMMAND_TYPE.includes(command); + } + // #region Command Options + const COMMAND_OPTIONS = [ + "mod", + "modb", + "reset" + ]; + const OVERRIDE_DICTIONARY = { + "mod": "modattr", + "modb": "modbattr", + "reset": "resetattr", + }; + function isCommandOption(option) { + return COMMAND_OPTIONS.includes(option); + } + // #region Targets + const TARGETS = [ + "all", + "allgm", + "allplayers", + "charid", + "name", + "sel", + "sel-noparty", + "sel-party", + "party", + ]; + // #region Feedback + const FEEDBACK_OPTIONS = [ + "fb-public", + "fb-from", + "fb-header", + "fb-content", + ]; + function isFeedbackOption(option) { + for (const fbOption of FEEDBACK_OPTIONS) { + if (option.startsWith(fbOption)) + return true; + } + return false; + } + function extractFeedbackKey(option) { + if (option === "fb-public") + return "public"; + if (option === "fb-from") + return "from"; + if (option === "fb-header") + return "header"; + if (option === "fb-content") + return "content"; + return false; + } + // #region Options + const OPTIONS = [ + "nocreate", + "evaluate", + "replace", + "silent", + "mute", + ]; + function isOption(option) { + return OPTIONS.includes(option); + } + // #region Alias Characters + const ALIAS_CHARACTERS = { + "<": "[", + ">": "]", + "~": "-", + ";": "?", + "`": "@", + }; + + // #region Inline Message Extraction and Validation + function validateMessage(content) { + for (const command of COMMAND_TYPE) { + const messageCommand = content.split(" ")[0]; + if (messageCommand === `!${command}`) { + return true; + } + } + return false; + } + function extractMessageFromRollTemplate(msg) { + for (const command of COMMAND_TYPE) { + if (msg.content.includes(command)) { + const regex = new RegExp(`(!${command}.*?)!!!`, "gi"); + const match = regex.exec(msg.content); + if (match) + return match[1].trim(); + } + } + return false; + } + // Roll20 delivers multiline commands wrapped in `{{ ... }}` with HTML line + // breaks (`
`). This mirrors ChatSetAttr 1.10's `parseOpts` preprocessing so + // a `{{ }}`-wrapped multiline command parses identically to its flat form. + function normalizeMultilineCommand(content) { + return content + .replace(//g, "") + .replace(/\s+$/g, "") + .replace(/\s*{{((?:.|\n)*)\s+}}$/, " $1") + .replace(/\\([{}])/g, "$1"); + } + // #region Message Parsing + function extractOperation(parts) { + if (parts.length === 0) { + log("Empty Command."); + return; + } + const commandPart = parts.shift(); + const tokens = commandPart.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) { + log("Empty Command."); + return; + } + if (!tokens[0].startsWith("!")) { + log("Invalid Command."); + return; + } + const command = tokens[0].slice(1); + if (!isCommand(command)) { + log("Invalid Command."); + return; + } + if (tokens.length > 1) { + parts.unshift(tokens.slice(1).join(" ")); + } + return command; + } + function extractReferences(value) { + if (typeof value !== "string") + return []; + const matches = value.matchAll(/%[a-zA-Z0-9_]+%/g); + return Array.from(matches, m => m[0]); + } + function splitMessage(content) { + const split = content.split("--").map(part => part.trim()); + return split; + } + function includesATarget(part) { + if (part.includes("|") || part.includes("#")) + return false; + [part] = part.split(" ").map(p => p.trim()); + for (const target of TARGETS) { + const isMatch = part.toLowerCase() === target.toLowerCase(); + if (isMatch) + return true; + } + return false; + } + function parseMessage(content) { + content = normalizeMultilineCommand(content); + const parts = splitMessage(content); + let operation = extractOperation(parts); + if (!operation) { + return; + } + const targeting = []; + const options = {}; + const changes = []; + const references = []; + const feedback = { public: false }; + for (const part of parts) { + if (isCommandOption(part)) { + operation = OVERRIDE_DICTIONARY[part]; + } + else if (isOption(part)) { + options[part] = true; + } + else if (includesATarget(part)) { + targeting.push(part); + } + else if (isFeedbackOption(part)) { + const [key, ...valueParts] = part.split(" "); + const value = valueParts.join(" "); + const feedbackKey = extractFeedbackKey(key); + if (!feedbackKey) + continue; + if (feedbackKey === "public") { + feedback.public = true; + } + else { + feedback[feedbackKey] = cleanValue(value); + } + } + else if (part.includes("|") || part.includes("#")) { + const split = part.split(/[|#]/g).map(p => p.trim()); + const [attrName, attrCurrent, attrMax] = split; + if (!attrName && !attrCurrent && !attrMax) { + continue; + } + const attribute = {}; + if (attrName) + attribute.name = attrName; + if (attrCurrent) + attribute.current = cleanValue(attrCurrent); + if (attrMax) + attribute.max = cleanValue(attrMax); + changes.push(attribute); + const currentMatches = extractReferences(attrCurrent); + const maxMatches = extractReferences(attrMax); + references.push(...currentMatches, ...maxMatches); + } + else { + const suspectedAttribute = part.replace(/[^-0-9A-Za-z_$]/g, ""); + if (!suspectedAttribute) + continue; + changes.push({ name: suspectedAttribute }); + } + } + return { + operation, + options, + targeting, + changes, + references, + feedback, + }; + } + + const REPEATING_INDEX_TOKEN = /^\$(\d+)$/i; + const REPEATING_CREATE_TOKEN = /^CREATE$/i; + const REPEATING_DASH_CREATE_TOKEN = /^-CREATE$/i; + function isRepeatingCreateToken(token) { + return REPEATING_CREATE_TOKEN.test(token) || REPEATING_DASH_CREATE_TOKEN.test(token); + } + function parseRepeatingIdentifierToken(token) { + if (!token) + return null; + const indexMatch = token.match(REPEATING_INDEX_TOKEN); + if (indexMatch) { + return { kind: "index", index: Number(indexMatch[1]) }; + } + if (isRepeatingCreateToken(token)) { + return { kind: "create" }; + } + return { kind: "rowId", rowId: token }; + } + function isRepeatingRowIdToken(token) { + const parsed = parseRepeatingIdentifierToken(token); + return parsed?.kind === "rowId"; + } + function resolveRowIdInRepOrder(repOrder, rowId) { + const rowIdLo = rowId.toLowerCase(); + const index = repOrder.findIndex(id => id.toLowerCase() === rowIdLo); + if (index === -1) + return null; + return repOrder[index]; + } + function parseRepeatingRowDeleteTarget(name) { + if (extractRepeatingParts(name)) { + return null; + } + const parts = name.split("_"); + if (parts.length !== 3) { + return null; + } + const [repeating, section, identifierToken] = parts; + if (repeating !== "repeating" || !section || !identifierToken) { + return null; + } + const parsed = parseRepeatingIdentifierToken(identifierToken); + if (!parsed || parsed.kind === "create") { + return null; + } + const sectionPrefix = `repeating_${section}`; + if (parsed.kind === "index") { + return { sectionPrefix, rowIndex: parsed.index }; + } + return { sectionPrefix, rowId: parsed.rowId }; + } + function getSectionFromRepeatingPrefix(sectionPrefix) { + const match = sectionPrefix.match(/^repeating_(.+)$/); + return match ? match[1] : null; + } + function resolveRepeatingRowId(target, repOrder) { + if (target.rowIndex !== undefined) { + if (target.rowIndex < 0 || target.rowIndex >= repOrder.length) { + return null; + } + return repOrder[target.rowIndex]; + } + if (target.rowId) { + return resolveRowIdInRepOrder(repOrder, target.rowId); + } + return null; + } + function findRepeatingRowAttributeNames(characterID, sectionPrefix, rowId) { + const prefix = `${sectionPrefix}_${rowId}_`.toUpperCase(); + const attributes = findObjs({ + _type: "attribute", + _characterid: characterID, + }); + const names = []; + for (const attribute of attributes) { + const name = attribute.get("name"); + if (typeof name !== "string") + continue; + if (name.toUpperCase().startsWith(prefix)) { + names.push(name); + } + } + return names; + } + function expandRepeatingRowDeletes(characterID, changes, repOrders, errors, characterName) { + const result = []; + for (const change of changes) { + if (!change.name) + continue; + const target = parseRepeatingRowDeleteTarget(change.name); + if (!target) { + result.push(change); + continue; + } + const section = getSectionFromRepeatingPrefix(target.sectionPrefix); + if (!section) { + result.push(change); + continue; + } + const repOrder = repOrders[section] || []; + const resolvedRowId = resolveRepeatingRowId(target, repOrder); + if (!resolvedRowId) { + if (target.rowIndex !== undefined) { + errors.push(`Repeating row number ${target.rowIndex} invalid for character ${characterName} and repeating section ${target.sectionPrefix}.`); + } + else { + errors.push(`Repeating row id ${target.rowId} invalid for character ${characterName} and repeating section ${target.sectionPrefix}.`); + } + continue; + } + const fieldNames = findRepeatingRowAttributeNames(characterID, target.sectionPrefix, resolvedRowId); + for (const name of fieldNames) { + result.push({ name }); + } + } + return result; + } + function extractRepeatingParts(attributeName) { + const [repeating, section, identifier, ...fieldParts] = attributeName.split("_"); + if (repeating !== "repeating") { + return null; + } + const field = fieldParts.join("_"); + if (!section || !identifier || !field) { + return null; + } + return { + section, + identifier, + field + }; + } + function hasCreateIdentifier(attributeName) { + const parts = extractRepeatingParts(attributeName); + if (parts) { + return isRepeatingCreateToken(parts.identifier); + } + return isRepeatingCreateToken(attributeName); + } + function hasIndexIdentifier(attributeName) { + const parts = extractRepeatingParts(attributeName); + if (!parts) + return false; + return REPEATING_INDEX_TOKEN.test(parts.identifier); + } + function convertRepOrderToArray(repOrder) { + return repOrder.split(",").map(id => id.trim()).filter(Boolean); + } + function discoverRowIds(characterID, section) { + const rowIds = new Set(); + const attributes = findObjs({ + _type: "attribute", + _characterid: characterID, + }); + for (const attribute of attributes) { + const name = attribute.get("name"); + if (typeof name !== "string") + continue; + const parts = name.split("_"); + if (parts.length < 4) + continue; + if (parts[0] !== "repeating" || parts[1] !== section) + continue; + const identifier = parts[2]; + if (isRepeatingRowIdToken(identifier)) { + rowIds.add(identifier); + } + } + return Array.from(rowIds); + } + function mergeRepOrder(storedOrder, discoveredIds) { + const discoveredSet = new Set(discoveredIds); + const ordered = storedOrder.filter(id => discoveredSet.has(id)); + for (const id of discoveredIds) { + if (!ordered.includes(id)) { + ordered.push(id); + } + } + return ordered; + } + async function getRepOrderForSection(characterID, section) { + const repOrderAttribute = `_reporder_repeating_${section}`; + const repOrder = await libSmartAttributes.getAttribute(characterID, repOrderAttribute); + return repOrder; + } + function getAllSectionNames(attributes) { + const sectionNames = new Set(); + for (const attr of attributes) { + if (!attr.name) + continue; + const parts = extractRepeatingParts(attr.name); + if (parts) { + sectionNames.add(parts.section); + continue; + } + const rowDelete = parseRepeatingRowDeleteTarget(attr.name); + if (rowDelete) { + const section = getSectionFromRepeatingPrefix(rowDelete.sectionPrefix); + if (section) { + sectionNames.add(section); + } + } + } + return Array.from(sectionNames); + } + async function getAllRepOrders(characterID, sectionNames) { + const repOrders = {}; + for (const section of sectionNames) { + const repOrderString = await getRepOrderForSection(characterID, section); + const stored = repOrderString && typeof repOrderString === "string" + ? convertRepOrderToArray(repOrderString) + : []; + const discovered = discoverRowIds(characterID, section); + repOrders[section] = mergeRepOrder(stored, discovered); + } + return repOrders; + } + + function processModifierValue(modification, resolvedAttributes, { shouldEvaluate = false, shouldAlias = false } = {}) { + let finalValue = replacePlaceholders(modification, resolvedAttributes); + if (shouldAlias) { + finalValue = replaceAliasCharacters(finalValue); + } + if (shouldEvaluate) { + finalValue = evaluateExpression(finalValue); + } + return finalValue; + } + function replaceAliasCharacters(modification) { + let result = modification; + for (const alias in ALIAS_CHARACTERS) { + const original = ALIAS_CHARACTERS[alias]; + const regex = new RegExp(`\\${alias}`, "g"); + result = result.replace(regex, original); + } + return result; + } + function replacePlaceholders(value, attributes) { + if (typeof value !== "string") + return value; + return value.replace(/%([a-zA-Z0-9_]+)%/g, (match, name) => { + const replacement = attributes[name]; + return replacement !== undefined ? String(replacement) : match; + }); + } + function evaluateExpression(expression) { + try { + const stringValue = String(expression); + const result = eval(stringValue); + return result; + } + catch { + return expression; + } + } + function processModifierName(name, { repeatingID, repOrder }) { + let result = name; + const hasCreate = result.includes("CREATE"); + if (hasCreate && repeatingID) { + if (/-CREATE/i.test(result)) { + result = result.replace(/-CREATE/i, repeatingID); + } + else { + result = result.replace(/CREATE/i, repeatingID); + } + } + const rowIndexMatch = result.match(/\$(\d+)/); + if (rowIndexMatch && repOrder) { + const rowIndex = parseInt(rowIndexMatch[1], 10); + const rowID = repOrder[rowIndex]; + if (!rowID) + return result; + result = result.replace(`$${rowIndex}`, rowID); + } + return result; + } + function processModifications(modifications, resolved, options, repOrders, errors = [], characterName = "") { + const processedModifications = []; + const repeatingID = libUUID.generateRowID(); + for (const mod of modifications) { + if (!mod.name) + continue; + let processedName = mod.name; + const parts = extractRepeatingParts(mod.name); + if (parts) { + const hasCreate = hasCreateIdentifier(parts.identifier); + const repOrder = repOrders[parts.section] || []; + processedName = processModifierName(mod.name, { + repeatingID: hasCreate ? repeatingID : parts.identifier, + repOrder, + }); + if (hasIndexIdentifier(mod.name)) { + const unresolvedIndex = processedName.match(/\$(\d+)/); + if (unresolvedIndex) { + errors.push(`Repeating row number ${unresolvedIndex[1]} invalid for character ${characterName} and repeating section repeating_${parts.section}.`); + continue; + } + } + } + let processedCurrent = undefined; + if (mod.current !== undefined && mod.current !== "undefined") { + processedCurrent = String(mod.current); + processedCurrent = processModifierValue(processedCurrent, resolved, { + shouldEvaluate: options.evaluate, + shouldAlias: options.replace, + }); + } + let processedMax = undefined; + if (mod.max !== undefined) { + processedMax = String(mod.max); + processedMax = processModifierValue(processedMax, resolved, { + shouldEvaluate: options.evaluate, + shouldAlias: options.replace, + }); + } + const processedMod = { + name: processedName, + }; + if (processedCurrent !== undefined) { + processedMod.current = processedCurrent; + } + if (processedMax !== undefined) { + processedMod.max = processedMax; + } + processedModifications.push(processedMod); + } + return processedModifications; + } + + const permissions = { + playerID: "", + isGM: false, + canModify: false, + }; + function checkPermissions(playerID) { + const player = getObj("player", playerID); + if (!player) { + if ("API" === playerID) { + // allow API full access + setPermissions(playerID, true, true); + return true; + } + log(`Player with ID ${playerID} not found.`); + return false; + } + const isGM = playerIsGM(playerID); + const config = getConfig(); + const playersCanModify = config.playersCanModify || false; + const canModify = isGM || playersCanModify; + setPermissions(playerID, isGM, canModify); + return true; + } + function setPermissions(playerID, isGM, canModify) { + permissions.playerID = playerID; + permissions.isGM = isGM; + permissions.canModify = canModify; + } + function getPermissions() { + return { ...permissions }; + } + function checkPermissionForTarget(playerID, target) { + const isAPI = "API" == playerID; + if (isAPI) { + return true; + } + const player = getObj("player", playerID); + if (!player) { + return false; + } + const isGM = playerIsGM(playerID); + if (isGM) { + return true; + } + if (getConfig().playersCanModify) { + return true; + } + const character = getObj("character", target); + if (!character) { + return false; + } + const controlledBy = (character.get("controlledby") || "").split(","); + return controlledBy.includes(playerID); + } + + function generateSelectedTargets(message, type) { + const errors = []; + const targets = []; + if (!message.selected) + return { targets, errors }; + for (const token of message.selected) { + const tokenObj = getObj("graphic", token._id); + if (!tokenObj) { + errors.push(`Selected token with ID ${token._id} not found.`); + continue; + } + if (tokenObj.get("_subtype") !== "token") { + errors.push(`Selected object with ID ${token._id} is not a token.`); + continue; + } + const represents = tokenObj.get("represents"); + const character = getObj("character", represents); + if (!character) { + errors.push(`Token with ID ${token._id} does not represent a character.`); + continue; + } + const inParty = character.get("inParty"); + if (type === "sel-noparty" && inParty) { + continue; + } + if (type === "sel-party" && !inParty) { + continue; + } + targets.push(character.id); + } + return { + targets, + errors, + }; + } + function generateAllTargets(type) { + const { isGM } = getPermissions(); + const errors = []; + if (!isGM) { + errors.push(`Only GMs can use the '${type}' target option.`); + return { + targets: [], + errors, + }; + } + const characters = findObjs({ _type: "character" }); + if (type === "all") { + return { + targets: characters.map(char => char.id), + errors, + }; + } + else if (type === "allgm") { + const targets = characters.filter(char => { + const controlledBy = char.get("controlledby"); + return !controlledBy; + }).map(char => char.id); + return { + targets, + errors, + }; + } + else if (type === "allplayers") { + const targets = characters.filter(char => { + const controlledBy = char.get("controlledby"); + return !!controlledBy; + }).map(char => char.id); + return { + targets, + errors, + }; + } + return { + targets: [], + errors: [`Unknown target type '${type}'.`], + }; + } + function generateCharacterIDTargets(values) { + const { playerID } = getPermissions(); + const targets = []; + const errors = []; + for (const charID of values) { + const character = getObj("character", charID); + if (!character) { + errors.push(`Character with ID ${charID} not found.`); + continue; + } + const characterID = character.id; + const hasPermission = checkPermissionForTarget(playerID, characterID); + if (!hasPermission) { + errors.push(`Permission error. You do not have permission to modify character with ID ${charID}.`); + continue; + } + targets.push(characterID); + } + return { + targets, + errors, + }; + } + function generatePartyTargets() { + const { isGM } = getPermissions(); + const { playersCanTargetParty } = getConfig(); + const targets = []; + const errors = []; + if (!isGM && !playersCanTargetParty) { + errors.push("Only GMs can use the 'party' target option."); + return { + targets, + errors, + }; + } + const characters = findObjs({ _type: "character", inParty: true }); + for (const character of characters) { + const characterID = character.id; + targets.push(characterID); + } + return { + targets, + errors, + }; + } + function splitCommaSeparatedValues(valueString) { + if (!valueString) { + return []; + } + return valueString.split(/\s*,\s*/).map(v => v.trim()).filter(v => v.length > 0); + } + function parseTargetOption(option) { + const trimmed = option.trim(); + const spaceIndex = trimmed.indexOf(" "); + if (spaceIndex === -1) { + return { type: trimmed, values: [] }; + } + const type = trimmed.slice(0, spaceIndex); + const remainder = trimmed.slice(spaceIndex + 1).trim(); + if (type === "name" || type === "charid") { + return { type, values: splitCommaSeparatedValues(remainder) }; + } + return { type, values: [] }; + } + function generateNameTargets(values) { + const { playerID } = getPermissions(); + const targets = []; + const errors = []; + for (const name of values) { + const characters = findObjs({ _type: "character", name }, { caseInsensitive: true }); + if (characters.length === 0) { + errors.push(`Character with name "${name}" not found.`); + continue; + } + if (characters.length > 1) { + errors.push(`Multiple characters found with name "${name}". Please use character ID instead.`); + continue; + } + const character = characters[0]; + const characterID = character.id; + const hasPermission = checkPermissionForTarget(playerID, characterID); + if (!hasPermission) { + errors.push(`Permission error. You do not have permission to modify character with name "${name}".`); + continue; + } + targets.push(characterID); + } + return { + targets, + errors, + }; + } + function generateTargets(message, targetOptions) { + const characterIDs = []; + const errors = []; + for (const option of targetOptions) { + const { type, values } = parseTargetOption(option); + if (type === "sel" || type === "sel-noparty" || type === "sel-party") { + const results = generateSelectedTargets(message, type); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "all" || type === "allgm" || type === "allplayers") { + const results = generateAllTargets(type); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "charid") { + const results = generateCharacterIDTargets(values); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "name") { + const results = generateNameTargets(values); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "party") { + const results = generatePartyTargets(); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + } + const targets = Array.from(new Set(characterIDs)); + return { + targets, + errors, + }; + } + + const timerMap = new Map(); + function startTimer(key, duration = 50, callback) { + // Clear any existing timer for the same key + const existingTimer = timerMap.get(key); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + callback(); + timerMap.delete(key); + }, duration); + timerMap.set(key, timer); + } + function clearTimer(key) { + const timer = timerMap.get(key); + if (timer) { + clearTimeout(timer); + timerMap.delete(key); + } + } + + function broadcastHeader() { + log(`${scriptJson.name} v${scriptJson.version} by ${scriptJson.authors.join(", ")} loaded.`); + } + function checkDependencies() { + const errors = []; + if (libSmartAttributes === undefined) { + errors.push("libSmartAttributes is required but not found. Please ensure the libSmartAttributes script is installed."); + } + if (libUUID === undefined) { + errors.push("libUUID is required but not found. Please ensure the libUUID script is installed."); + } + if (errors.length > 0) { + sendErrors("gm", "Missing Dependencies", errors); + } + return errors.length === 0; + } + async function acceptMessage(msg) { + // State + const errors = []; + const messages = []; + const result = {}; + // Parse Message + const parsed = parseMessage(msg.content); + if (!parsed) { + return errorOut("Could not parse command. Check that command options use -- (double dash).", msg.playerid, errors, normalizeCommandOutputOptions()); + } + const { operation, targeting, options, changes, references, feedback, } = parsed; + const output = normalizeCommandOutputOptions(options); + // Start Timer + startTimer("chatsetattr", 8000, () => sendDelayMessage(msg.playerid, output)); + // Check Config and Permissions + const config = getConfig(); + const isAPI = "API" === msg.playerid; + const isGM = playerIsGM(msg.playerid); + if (options.evaluate && !isAPI && !isGM && !config.playersCanEvaluate) { + return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors, output); + } + if (targeting.includes("party") && !isAPI && !isGM && !config.playersCanTargetParty) { + return errorOut("You do not have permission to target the party.", msg.playerid, errors, output); + } + if ((operation === "modattr" || operation === "modbattr") && !isAPI && !isGM && !config.playersCanModify) { + return errorOut("You do not have permission to modify attributes.", msg.playerid, errors, output); + } + // Preprocess + const { targets, errors: targetErrors } = generateTargets(msg, targeting); + errors.push(...targetErrors); + if (targets.length === 0) { + return errorOut("No valid targets found.", msg.playerid, errors, output); + } + const request = generateRequest(references, changes); + const command = handlers[operation]; + if (!command) { + return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors, output); + } + // Execute + const priorValues = {}; + const pendingChanges = {}; + for (const target of targets) { + const attrs = await getAttributes(target, request); + priorValues[target] = attrs; + const sectionNames = getAllSectionNames(changes); + const repOrders = await getAllRepOrders(target, sectionNames); + let effectiveChanges = changes; + if (operation === "delattr") { + effectiveChanges = expandRepeatingRowDeletes(target, changes, repOrders, errors, getCharName(target)); + } + const modifications = processModifications(effectiveChanges, attrs, options, repOrders, errors, getCharName(target)); + const response = await command(modifications, target, references, options.nocreate, feedback); + if (response.errors.length > 0) { + errors.push(...response.errors); + continue; + } + pendingChanges[target] = modifications; + result[target] = response.result; + } + const updateResult = await makeUpdate(operation, result, { + noCreate: options.nocreate, + priorValues}); + clearTimer("chatsetattr"); + errors.push(...updateResult.errors); + for (const target in result) { + const filteredResult = filterSuccessfulResult(target, result[target], updateResult.failed); + if (Object.keys(filteredResult).length === 0) { + continue; + } + const characterName = getCharName(target); + const targetChanges = pendingChanges[target] ?? []; + let message; + if (feedback?.content) { + message = createFeedbackMessage(characterName, feedback, priorValues[target] ?? {}, filteredResult); + } + else if (operation === "delattr") { + message = formatDeleteFeedback(characterName, targetChanges, filteredResult); + } + else { + message = formatSettingFeedback(characterName, targetChanges, filteredResult); + } + if (message) { + messages.push(message); + } + } + sendErrors(msg.playerid, "Errors", errors, feedback?.from, output); + const delSetTitle = operation === "delattr" ? "Deleting attributes" : "Setting attributes"; + const feedbackTitle = feedback?.header ?? delSetTitle; + if (messages.length > 0) { + sendMessages(msg.playerid, feedbackTitle, messages, { + from: feedback?.from, + public: feedback?.public, + }, output); + } + } + function errorOut(errorText, playerid, errors, output) { + errors.push(errorText); + sendErrors(playerid, "Errors", errors, undefined, output); + clearTimer("chatsetattr"); + } + function filterSuccessfulResult(target, targetResult, failed) { + const filtered = {}; + for (const key in targetResult) { + if (!failed.includes(`${target}:${key}`)) { + filtered[key] = targetResult[key]; + } + } + return filtered; + } + function generateRequest(references, changes) { + const referenceSet = new Set(references); + for (const change of changes) { + if (!change.name) { + continue; + } + if (!referenceSet.has(change.name)) { + referenceSet.add(change.name); + } + const maxName = `${change.name}_max`; + if (!referenceSet.has(maxName)) { + referenceSet.add(maxName); + } + } + return Array.from(referenceSet); + } + function registerHandlers() { + broadcastHeader(); + if (!checkDependencies()) { + return; + } + if (!isBeaconSupported()) { + sendBeaconUnsupportedNotice(); + } + on("chat:message", (msg) => { + if (msg.type !== "api") { + const inlineMessage = extractMessageFromRollTemplate(msg); + if (!inlineMessage) + return; + msg.content = inlineMessage; + } + msg.content = normalizeMultilineCommand(msg.content); + msg.content = normalizeTemplateRollProperties(msg.content); + msg.content = processInlinerolls(msg); + const debugReset = msg.content.startsWith("!setattrs-debugreset"); + if (debugReset) { + log("ChatSetAttr: Debug - resetting state."); + state.ChatSetAttr = {}; + return; + } + const debugVersion = msg.content.startsWith("!setattrs-debugversion"); + if (debugVersion) { + log("ChatSetAttr: Debug - setting state schema version to 3."); + if (!state.ChatSetAttr) + state.ChatSetAttr = {}; + state.ChatSetAttr.version = 3; + return; + } + const isHelpMessage = checkHelpMessage(msg.content); + if (isHelpMessage) { + handleHelpCommand(); + return; + } + const isConfigMessage = checkConfigMessage(msg.content); + if (isConfigMessage) { + if (!playerIsGM(msg.playerid)) { + return; + } + handleConfigCommand(msg.content, msg.playerid); + return; + } + const validMessage = validateMessage(msg.content); + if (!validMessage) + return; + if (checkPermissions(msg.playerid)) { + acceptMessage(msg); + } + }); + } + + const LI_STYLE = s({ + marginBottom: "4px", + }); + const WRAPPER_STYLE = s(frameStyleBase); + const PARAGRAPH_SPACING_STYLE = s({ + marginTop: "8px", + marginBottom: "8px", + }); + function createVersionMessage() { + return (h("div", { style: WRAPPER_STYLE }, + h("p", null, + h("strong", null, "ChatSetAttr has been updated to version 2.0!")), + h("p", null, "This update includes important changes to improve compatibility and performance."), + h("strong", null, "Changelog:"), + h("ul", null, + h("li", { style: LI_STYLE }, "Added compatibility for Beacon sheets, including the new Dungeons and Dragons character sheet."), + h("li", { style: LI_STYLE }, + "Added support for targeting party members with the ", + h("code", null, "--party"), + " flag."), + h("li", { style: LI_STYLE }, + "Added support for excluding party members when targeting selected tokens with the ", + h("code", null, "--sel-noparty"), + " flag."), + h("li", { style: LI_STYLE }, + "Added support for including only party members when targeting selected tokens with the ", + h("code", null, "--sel-party"), + " flag.")), + h("p", null, "Please review the updated documentation for details on these new features and how to use them."), + h("div", { style: PARAGRAPH_SPACING_STYLE }, + h("strong", null, + "If you encounter any bugs or issues, please report them via the ", + h("a", { href: "https://help.roll20.net/hc/en-us/requests/new" }, "Roll20 Helpdesk"))), + h("div", { style: PARAGRAPH_SPACING_STYLE }, + h("strong", null, + "If you want to create a handout with the updated documentation, use the command ", + h("code", null, "!setattr-help"), + " or click the button below"), + h("a", { href: "!setattr-help" }, "Create Help Handout")))).html; + } + + const v2_0 = { + appliesTo: "<=3", + version: 4, + update: () => { + setConfig({ + version: 4, + playersCanTargetParty: true, + scriptVersion: scriptJson.version, + }); + const title = "ChatSetAttr Updated to Version 2.0"; + const content = createVersionMessage(); + sendNotification(title, content, false); + }, + }; + + const VERSION_HISTORY = [ + v2_0, + ]; + function welcome() { + const hasWelcomed = hasFlag("welcome"); + if (hasWelcomed) { + return; + } + sendWelcomeMessage(); + setFlag("welcome"); + } + function update() { + log("ChatSetAttr: Checking for state schema updates..."); + const currentSchemaVersion = getPersistedSchemaVersion(); + log(`ChatSetAttr: Current state schema version: ${currentSchemaVersion}`); + checkForUpdates(currentSchemaVersion); + persistStateVersionMetadata(); + } + function checkForUpdates(currentSchemaVersion) { + for (const migration of VERSION_HISTORY) { + log(`ChatSetAttr: Evaluating schema migration to ${migration.version} (appliesTo: ${migration.appliesTo})`); + const applies = migration.appliesTo; + const threshold = Number(applies.replace(/(<=|<|>=|>|=)/, "").trim()); + const comparison = applies.replace(String(threshold), "").trim(); + const compared = compareSchemaVersions(currentSchemaVersion, threshold); + let shouldApply = false; + switch (comparison) { + case "<=": + shouldApply = compared <= 0; + break; + case "<": + shouldApply = compared < 0; + break; + case ">=": + shouldApply = compared >= 0; + break; + case ">": + shouldApply = compared > 0; + break; + case "=": + shouldApply = compared === 0; + break; + } + if (shouldApply) { + migration.update(); + currentSchemaVersion = migration.version; + updateVersionInState(currentSchemaVersion); + } + } + } + function compareSchemaVersions(current, threshold) { + return current - threshold; + } + function updateVersionInState(newSchemaVersion) { + setConfig({ version: newSchemaVersion }); + } + + on("ready", () => { + checkGlobalConfig(); + registerHandlers(); + syncHelpHandoutOnStartup(); + syncScriptVersion(); + update(); + welcome(); + persistStateVersionMetadata(); + }); + + exports.registerObserver = registerObserver; + + return exports; + +})({}); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index e4cd91e789..8f51e5270d 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -1,785 +1,3807 @@ -// ChatSetAttr version 1.10 -// Last Updated: 2020-09-03 -// A script to create, modify, or delete character attributes from the chat area or macros. -// If you don't like my choices for --replace, you can edit the replacers variable at your own peril to change them. - -/* global log, state, globalconfig, getObj, sendChat, _, getAttrByName, findObjs, createObj, playerIsGM, on */ -const ChatSetAttr = (function () { - "use strict"; - const version = "1.10", - observers = { - "add": [], - "change": [], - "destroy": [] - }, - schemaVersion = 3, - replacers = [ - [//g, "]"], - [/\\rbrak/g, "]"], - [/;/g, "?"], - [/\\ques/g, "?"], - [/`/g, "@"], - [/\\at/g, "@"], - [/~/g, "-"], - [/\\n/g, "\n"], - ], - // Basic Setup - checkInstall = function () { - log(`-=> ChatSetAttr v${version} <=-`); - if (!state.ChatSetAttr || state.ChatSetAttr.version !== schemaVersion) { - log(` > Updating ChatSetAttr Schema to v${schemaVersion} <`); - state.ChatSetAttr = { - version: schemaVersion, - globalconfigCache: { - lastsaved: 0 - }, - playersCanModify: false, - playersCanEvaluate: false, - useWorkers: true - }; - } - checkGlobalConfig(); - }, - checkGlobalConfig = function () { - const s = state.ChatSetAttr, - g = globalconfig && globalconfig.chatsetattr; - if (g && g.lastsaved && g.lastsaved > s.globalconfigCache.lastsaved) { - log(" > Updating ChatSetAttr from Global Config < [" + - (new Date(g.lastsaved * 1000)) + "]"); - s.playersCanModify = "playersCanModify" === g["Players can modify all characters"]; - s.playersCanEvaluate = "playersCanEvaluate" === g["Players can use --evaluate"]; - s.useWorkers = "useWorkers" === g["Trigger sheet workers when setting attributes"]; - s.globalconfigCache = globalconfig.chatsetattr; - } - }, - // Utility functions - isDef = function (value) { - return value !== undefined; - }, - getWhisperPrefix = function (playerid) { - const player = getObj("player", playerid); - if (player && player.get("_displayname")) { - return "/w \"" + player.get("_displayname") + "\" "; - } else { - return "/w GM "; - } - }, - sendChatMessage = function (msg, from) { - if (from === undefined) from = "ChatSetAttr"; - sendChat(from, msg, null, { - noarchive: true - }); - }, - setAttribute = function (attr, value) { - if (state.ChatSetAttr.useWorkers) attr.setWithWorker(value); - else attr.set(value); - }, - handleErrors = function (whisper, errors) { - if (errors.length) { - const output = whisper + - "
" + - "

Errors

" + - `

${errors.join("
")}

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

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

!setattr-config --option
" + - "

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

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

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

" + - "

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

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

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

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

"; - sendChatMessage(output, opts["fb-from"]); - }, - handleCommand = (content, playerid, selected, pre) => { - // Parsing input - let charIDList = [], - errors = []; - const hasValue = ["charid", "name", "fb-header", "fb-content", "fb-from"], - optsArray = ["all", "allgm", "charid", "name", "allplayers", "sel", "deletemode", - "replace", "nocreate", "mod", "modb", "evaluate", "silent", "reset", "mute", - "fb-header", "fb-content", "fb-from", "fb-public" - ], - opts = parseOpts(content, hasValue), - isGM = playerid === "API" || playerIsGM(playerid), - whisper = getWhisperPrefix(playerid); - opts.mod = opts.mod || (pre === "mod"); - opts.modb = opts.modb || (pre === "modb"); - opts.reset = opts.reset || (pre === "reset"); - opts.silent = opts.silent || opts.mute; - opts.deletemode = (pre === "del"); - // Sanitise feedback - if ("fb-from" in opts) opts["fb-from"] = String(opts["fb-from"]); - // Parse desired attribute values - const [setting, rData] = parseAttributes(Object.keys(_.omit(opts, optsArray)), opts, errors); - // Fill in header info - if ("fb-header" in opts) { - opts["fb-header"] = Object.entries(setting).reduce((m, [n, v], k) => { - return m.replace(`_NAME${k}_`, n) - .replace(`_TCUR${k}_`, htmlReplace(v.current || "")) - .replace(`_TMAX${k}_`, htmlReplace(v.max || "")); - }, String(opts["fb-header"])).replace(/_(?:TCUR|TMAX|NAME)\d*_/g, ""); - } - if (opts.evaluate && !isGM && !state.ChatSetAttr.playersCanEvaluate) { - if (!opts.mute) handleErrors(whisper, ["The --evaluate option is only available to the GM."]); - return; - } - // Get list of character IDs - if (opts.all && isGM) { - charIDList = findObjs({ - _type: "character" - }).map(c => c.id); - } else if (opts.allgm && isGM) { - charIDList = findObjs({ - _type: "character" - }).filter(c => c.get("controlledby") === "") - .map(c => c.id); - } else if (opts.allplayers && isGM) { - charIDList = findObjs({ - _type: "character" - }).filter(c => c.get("controlledby") !== "") - .map(c => c.id); - } else { - if (opts.charid) charIDList.push(...opts.charid.split(/\s*,\s*/)); - if (opts.name) charIDList.push(...getIDsFromNames(opts.name, errors)); - if (opts.sel) charIDList.push(...getIDsFromTokens(selected)); - charIDList = checkPermissions([...new Set(charIDList)], errors, playerid, isGM); - } - if (charIDList.length === 0) { - errors.push("No target characters. You need to supply one of --all, --allgm, --sel," + - " --allplayers, --charid, or --name."); - } - if (Object.keys(setting).length === 0) { - errors.push("No attributes supplied."); - } - // Get attributes - if (!opts.mute) handleErrors(whisper, errors); - // Set or delete attributes - if (charIDList.length > 0 && Object.keys(setting).length > 0) { - if (opts.deletemode) { - delayedDeleteAttributes(whisper, charIDList, setting, errors, rData, opts); - } else { - delayedGetAndSetAttributes(whisper, charIDList, setting, errors, rData, opts); - } - } - }, - handleInlineCommand = (msg) => { - const command = msg.content.match(/!(set|mod|modb)attr .*?!!!/); - - if (command) { - const mode = command[1], - newMsgContent = command[0].slice(0, -3).replace(/{{[^}[\]]+\$\[\[(\d+)\]\].*?}}/g, (_, number) => { - return `$[[${number}]]`; - }); - const newMsg = { - content: newMsgContent, - inlinerolls: msg.inlinerolls, - }; - handleCommand( - processInlinerolls(newMsg), - msg.playerid, - msg.selected, - mode - ); - } - }, - // Main function, called after chat message input - handleInput = function (msg) { - if (msg.type !== "api") handleInlineCommand(msg); - else { - const mode = msg.content.match(/^!(reset|set|del|mod|modb)attr\b(?:-|\s|$)(config)?/); - - if (mode && mode[2]) { - if (playerIsGM(msg.playerid)) { - const whisper = getWhisperPrefix(msg.playerid), - opts = parseOpts(msg.content, []); - if (opts["players-can-modify"]) { - state.ChatSetAttr.playersCanModify = !state.ChatSetAttr.playersCanModify; - } - if (opts["players-can-evaluate"]) { - state.ChatSetAttr.playersCanEvaluate = !state.ChatSetAttr.playersCanEvaluate; - } - if (opts["use-workers"]) { - state.ChatSetAttr.useWorkers = !state.ChatSetAttr.useWorkers; - } - showConfig(whisper); - } - } else if (mode) { - handleCommand( - processInlinerolls(msg), - msg.playerid, - msg.selected, - mode[1] - ); - } - } - return; - }, - notifyObservers = function(event, obj, prev) { - observers[event].forEach(observer => observer(obj, prev)); - }, - registerObserver = function (event, observer) { - if(observer && _.isFunction(observer) && observers.hasOwnProperty(event)) { - observers[event].push(observer); - } else { - log("ChatSetAttr event registration unsuccessful. Please check the documentation."); - } - }, - registerEventHandlers = function () { - on("chat:message", handleInput); - }; - return { - checkInstall, - registerObserver, - registerEventHandlers - }; -}()); - -on("ready", function () { - "use strict"; - ChatSetAttr.checkInstall(); - ChatSetAttr.registerEventHandlers(); -}); \ No newline at end of file +// ChatSetAttr v2.0 by Jakob, GUD Team +var ChatSetAttr = (function (exports) { + 'use strict'; + + var name = "ChatSetAttr"; + var version = "2.0"; + var authors = [ + "Jakob", + "GUD Team" + ]; + var scriptJson = { + name: name, + version: version, + authors: authors}; + + // #region Style Helpers + function convertCamelToKebab(camel) { + return camel.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + } + function s(styleObject = {}) { + let style = ""; + for (const [key, value] of Object.entries(styleObject)) { + const kebabKey = convertCamelToKebab(key); + style += `${kebabKey}: ${value};`; + } + return style; + } + function escapeHtml$1(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + class SafeHtml { + html; + constructor(html) { + this.html = html; + } + } + function rawHtml(html) { + return new SafeHtml(html); + } + function renderChild(child) { + if (child instanceof SafeHtml) { + return child.html; + } + if (typeof child === "string") { + return escapeHtml$1(child); + } + return ""; + } + function h(tagName, attributes = {}, ...children) { + const attrs = Object.entries(attributes ?? {}) + .map(([key, value]) => ` ${key}="${escapeHtml$1(String(value))}"`) + .join(""); + const flattenedChildren = children.flat(10).filter(child => child != null); + const childrenContent = flattenedChildren.map(renderChild).join(""); + return new SafeHtml(`<${tagName}${attrs}>${childrenContent}`); + } + + const buttonStyleBase = { + border: "none", + borderRadius: "4px", + padding: "4px 8px", + backgroundColor: "rgba(233, 30, 162, 1)", + color: "rgba(255, 255, 255, 1)", + cursor: "pointer", + fontWeight: "500", + }; + const frameStyleBase = { + border: "1px solid rgba(59, 130, 246, 0.3)", + borderRadius: "8px", + padding: "8px", + backgroundColor: "rgba(59, 130, 246, 0.1)", + }; + const frameStyleNotice = { + border: "1px solid rgba(245, 158, 11, 0.55)", + borderRadius: "8px", + padding: "8px", + backgroundColor: "rgba(245, 158, 11, 0.18)", + }; + const frameStyleError = { + border: "1px solid rgba(239, 68, 68, 0.4)", + backgroundColor: "rgba(239, 68, 68, 0.1)", + }; + const headerStyleBase = { + fontSize: "1.5em", + marginBottom: "0.5em", + }; + + const CHAT_WRAPPER_STYLE = s(frameStyleBase); + const CHAT_HEADER_STYLE = s(headerStyleBase); + const CHAT_BODY_STYLE = s({ + fontSize: "14px", + lineHeight: "1.4", + }); + const ERROR_WRAPPER_STYLE = s({ + ...frameStyleBase, + ...frameStyleError, + }); + const ERROR_HEADER_STYLE = s(headerStyleBase); + const ERROR_BODY_STYLE = s({ + fontSize: "14px", + lineHeight: "1.4", + }); + // #region Generic Message Creation Function + function createMessage(header, messages, styles) { + return (h("div", { style: styles.wrapper }, + h("h3", { style: styles.header }, header), + h("div", { style: styles.body }, messages.map(message => h("p", null, message))))).html; + } + // #region Chat Message Function + function createChatMessage(header, messages) { + return createMessage(header, messages, { + wrapper: CHAT_WRAPPER_STYLE, + header: CHAT_HEADER_STYLE, + body: CHAT_BODY_STYLE + }); + } + // #region Error Message Function + function createErrorMessage(header, errors) { + return createMessage(header, errors, { + wrapper: ERROR_WRAPPER_STYLE, + header: ERROR_HEADER_STYLE, + body: ERROR_BODY_STYLE + }); + } + + const NOTICE_WRAPPER_STYLE = s(frameStyleNotice); + const NOTICE_HEADER_STYLE = s(headerStyleBase); + function createNoticeMessage(title, content) { + return (h("div", { style: NOTICE_WRAPPER_STYLE }, + h("div", { style: NOTICE_HEADER_STYLE }, title), + h("div", null, content))).html; + } + + const NOTIFY_WRAPPER_STYLE = s(frameStyleBase); + const NOTIFY_HEADER_STYLE = s(headerStyleBase); + function createNotifyMessage(title, content) { + return (h("div", { style: NOTIFY_WRAPPER_STYLE }, + h("div", { style: NOTIFY_HEADER_STYLE }, title), + h("div", null, rawHtml(content)))).html; + } + + function createWelcomeMessage() { + const buttonStyle = s(buttonStyleBase); + return (h("div", null, + h("p", null, "Thank you for installing ChatSetAttr."), + h("p", null, + "To get started, use the command ", + h("code", null, "!setattr-config"), + " to configure the script to your needs."), + h("p", null, + "For detailed documentation and examples, please use the ", + h("code", null, "!setattr-help"), + " command or click the button below:"), + h("p", null, + h("a", { href: "!setattr-help", style: buttonStyle }, "Create Journal Handout")))).html; + } + + const BEACON_UNSUPPORTED_NOTICE_TITLE = "Notice: Beacon Support Disabled"; + const BEACON_UNSUPPORTED_NOTICE_BODY = "Beacon character sheets are not supported on this Mod API Sandbox. " + + "Please be sure you have the correct Sandbox selected on the Mod API Scripts Page " + + "and restart the Mod API Server."; + const LONG_RUNNING_QUERY_TITLE = "Long Running Query"; + const LONG_RUNNING_QUERY_BODY = "The operation is taking a long time to execute. This may be due to a large number of " + + "targets or attributes being processed. Please be patient as the operation completes."; + function getWhisperPrefix(playerID) { + const player = getPlayerName(playerID); + return `/w "${player || "GM"}" `; + } + function normalizeCommandOutputOptions(options = {}) { + return { + mute: Boolean(options.mute), + silent: Boolean(options.silent || options.mute), + }; + } + function getPlayerName(playerID) { + const player = getObj("player", playerID); + return player?.get("_displayname") || undefined; + } + function sendMessages(playerID, header, messages, delivery, output) { + if (output?.silent) { + return; + } + const from = delivery?.from ?? "ChatSetAttr"; + const newMessage = createChatMessage(header, messages); + const chatMessage = delivery?.public + ? newMessage + : `${getWhisperPrefix(playerID)}${newMessage}`; + sendChat(from, chatMessage); + } + function sendErrors(playerID, header, errors, from, output) { + if (errors.length === 0 || output?.mute) { + return; + } + const sender = from ?? "ChatSetAttr"; + const newMessage = createErrorMessage(header, errors); + sendChat(sender, `${getWhisperPrefix(playerID)}${newMessage}`); + } + function sendDelayMessage(playerID, output) { + if (output?.silent) { + return; + } + const noticeMessage = createNoticeMessage(LONG_RUNNING_QUERY_TITLE, LONG_RUNNING_QUERY_BODY); + sendChat("ChatSetAttr", `${getWhisperPrefix(playerID)}${noticeMessage}`, undefined, { noarchive: true }); + } + function sendBeaconUnsupportedNotice() { + const message = createNoticeMessage(BEACON_UNSUPPORTED_NOTICE_TITLE, BEACON_UNSUPPORTED_NOTICE_BODY); + sendChat("ChatSetAttr", "/w gm " + message, undefined, { noarchive: true }); + } + function sendNotification(title, content, archive) { + const notifyMessage = createNotifyMessage(title, content); + sendChat("ChatSetAttr", "/w gm " + notifyMessage, undefined, { noarchive: archive }); + } + function sendWelcomeMessage() { + const welcomeMessage = createWelcomeMessage(); + sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); + } + + const CONFIG_WRAPPER_STYLE = s(frameStyleBase); + const CONFIG_HEADER_STYLE = s(headerStyleBase); + const CONFIG_TABLE_STYLE = s({ + width: "100%", + border: "none", + borderCollapse: "separate", + borderSpacing: "0 4px", + }); + const CONFIG_ROW_STYLE = s({ + marginBottom: "4px", + }); + const CONFIG_BUTTON_STYLE_ON = s({ + ...buttonStyleBase, + backgroundColor: "#16A34A", + color: "#FFFFFF", + fontWeight: "500", + }); + const CONFIG_BUTTON_STYLE_OFF = s({ + ...buttonStyleBase, + backgroundColor: "#DC2626", + color: "#FFFFFF", + fontWeight: "500", + }); + const CONFIG_CLEAR_FIX_STYLE = s({ + clear: "both", + }); + function camelToKebabCase(str) { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + } + function createConfigMessage() { + const config = getConfig(); + const configEntries = Object.entries(config); + const relevantEntries = configEntries.filter(([key]) => key !== "version" + && key !== "scriptVersion" + && key !== "globalconfigCache" + && key !== "flags" + && key !== "helpContentUpdatedAt"); + return (h("div", { style: CONFIG_WRAPPER_STYLE }, + h("div", { style: CONFIG_HEADER_STYLE }, "ChatSetAttr Configuration"), + h("div", null, + h("table", { style: CONFIG_TABLE_STYLE }, relevantEntries.map(([key, value]) => (h("tr", { style: CONFIG_ROW_STYLE }, + h("td", null, + h("strong", null, + key, + ":")), + h("td", null, + h("a", { href: `!setattr-config --${camelToKebabCase(key)}`, style: value ? CONFIG_BUTTON_STYLE_ON : CONFIG_BUTTON_STYLE_OFF }, value ? "Enabled" : "Disabled")))))), + h("div", { style: CONFIG_CLEAR_FIX_STYLE })))).html; + } + + const STATE_SCHEMA_VERSION = 4; + const GLOBAL_CONFIG_OPTIONS = [ + { + label: "Players can modify all characters", + key: "playersCanModify", + value: "playersCanModify", + }, + { + label: "Players can use --evaluate", + key: "playersCanEvaluate", + value: "playersCanEvaluate", + }, + { + label: "Trigger sheet workers when setting attributes", + key: "useWorkers", + value: "useWorkers", + }, + { + label: "Players can target party members", + key: "playersCanTargetParty", + value: "playersCanTargetParty", + }, + ]; + const DEFAULT_CONFIG = { + version: STATE_SCHEMA_VERSION, + scriptVersion: scriptJson.version, + globalconfigCache: { + lastsaved: 0, + }, + playersCanTargetParty: true, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + helpContentUpdatedAt: 0, + flags: [], + }; + function getStateSchemaVersion(raw) { + if (raw === undefined || raw === null) { + return 0; + } + if (typeof raw === "number" && Number.isFinite(raw)) { + return raw; + } + if (typeof raw === "string") { + const parsed = Number(raw); + if (Number.isFinite(parsed) && /^\d+$/.test(raw.trim())) { + return parsed; + } + return 0; + } + return 0; + } + function ensureChatSetAttrState() { + if (!state.ChatSetAttr) { + state.ChatSetAttr = {}; + } + return state.ChatSetAttr; + } + function getPersistedSchemaVersion() { + return getStateSchemaVersion(state.ChatSetAttr?.version); + } + function persistStateVersionMetadata() { + const raw = ensureChatSetAttrState(); + const schemaVersion = getStateSchemaVersion(raw.version); + if (schemaVersion > 0 && raw.version !== schemaVersion) { + raw.version = schemaVersion; + } + if (!Object.hasOwn(raw, "scriptVersion") || raw.scriptVersion !== scriptJson.version) { + raw.scriptVersion = scriptJson.version; + } + } + function syncScriptVersion() { + persistStateVersionMetadata(); + } + function parseGlobalConfigCheckbox(g, label, valueField) { + return g[label] === valueField; + } + function buildCacheSnapshot(g) { + const cache = { lastsaved: g.lastsaved ?? 0 }; + for (const option of GLOBAL_CONFIG_OPTIONS) { + cache[option.label] = `${g[option.label] ?? ""}`; + } + return cache; + } + function checkGlobalConfig() { + const g = globalconfig?.chatsetattr; + if (!g?.lastsaved) { + return []; + } + state.ChatSetAttr = state.ChatSetAttr || {}; + const cache = (state.ChatSetAttr.globalconfigCache || { lastsaved: 0 }); + if (g.lastsaved <= cache.lastsaved) { + return []; + } + const changes = []; + for (const option of GLOBAL_CONFIG_OPTIONS) { + const newRaw = `${g[option.label] ?? ""}`; + const oldRaw = `${cache[option.label] ?? ""}`; + if (newRaw === oldRaw) { + continue; + } + const newValue = parseGlobalConfigCheckbox(g, option.label, option.value); + const oldValue = getConfig()[option.key]; + if (newValue === oldValue) { + continue; + } + state.ChatSetAttr[option.key] = newValue; + changes.push(`${option.key}: ${String(oldValue)} → ${String(newValue)}`); + } + state.ChatSetAttr.globalconfigCache = buildCacheSnapshot(g); + if (changes.length > 0) { + log(`ChatSetAttr: Imported Global Config settings: ${changes.join(", ")}`); + sendNotification("ChatSetAttr Global Config", `

New settings imported from Global Config:

    ${changes.map(change => `
  • ${change}
  • `).join("")}
`, false); + } + return changes; + } + function getConfig() { + const stateConfig = state?.ChatSetAttr || {}; + return { + ...DEFAULT_CONFIG, + ...stateConfig, + }; + } + function setConfig(newConfig) { + Object.assign(ensureChatSetAttrState(), newConfig); + } + function hasFlag(flag) { + const config = getConfig(); + return config.flags.includes(flag); + } + function setFlag(flag) { + const config = getConfig(); + if (!hasFlag(flag)) { + config.flags.push(flag); + setConfig({ flags: config.flags }); + } + } + function checkConfigMessage(message) { + return message.startsWith("!setattr-config"); + } + const FLAG_MAP = { + "--players-can-modify": "playersCanModify", + "--players-can-evaluate": "playersCanEvaluate", + "--players-can-target-party": "playersCanTargetParty", + "--use-workers": "useWorkers", + }; + function handleConfigCommand(message, playerID) { + message = message.replace("!setattr-config", "").trim(); + const args = message.split(/\s+/); + const newConfig = {}; + for (const arg of args) { + const cleanArg = arg.toLowerCase(); + const flag = FLAG_MAP[cleanArg]; + if (flag !== undefined) { + newConfig[flag] = !getConfig()[flag]; + log(`Toggled config option: ${flag} to ${newConfig[flag]}`); + } + } + setConfig(newConfig); + const configMessage = createConfigMessage(); + sendChat("ChatSetAttr", `${getWhisperPrefix(playerID)}${configMessage}`, undefined, { noarchive: true }); + } + + const observers = {}; + function registerObserver(event, callback) { + if (!observers[event]) { + observers[event] = []; + } + observers[event].push(callback); + } + function notifyObservers(event, obj, prev) { + const callbacks = observers[event] || []; + callbacks.forEach(callback => { + callback(obj, prev); + }); + } + + const WRITABLE_KEYS = new Set(["current", "max"]); + function normalizeKey(key) { + return key.startsWith("_") ? key.slice(1) : key; + } + function toAttrString(value) { + if (value === undefined || value === null) { + return ""; + } + return String(value); + } + function hasSheetItemValue(value) { + return value !== null && value !== undefined && value !== ""; + } + function hasPriorValue$1(value) { + return value !== undefined && value !== null && value !== ""; + } + function toSnapshot(targetId, actualName, kind, state, id = "") { + return { + _id: id, + _type: kind, + _characterid: targetId, + name: actualName, + current: state.current, + max: state.max, + }; + } + function mergeAttributeState(targetId, actualName, priorValues, results, isDelete) { + const maxKey = `${actualName}_max`; + const priorCurrent = priorValues[targetId]?.[actualName]; + const priorMax = priorValues[targetId]?.[maxKey]; + if (isDelete) { + return { + current: toAttrString(priorCurrent), + max: toAttrString(priorMax), + priorCurrent: toAttrString(priorCurrent), + priorMax: toAttrString(priorMax), + }; + } + const newCurrent = results[targetId]?.[actualName]; + const newMax = results[targetId]?.[maxKey]; + return { + current: newCurrent !== undefined ? toAttrString(newCurrent) : toAttrString(priorCurrent), + max: newMax !== undefined ? toAttrString(newMax) : toAttrString(priorMax), + priorCurrent: toAttrString(priorCurrent), + priorMax: toAttrString(priorMax), + }; + } + function tryFindLegacyAttribute(targetId, actualName) { + return findObjs({ + _type: "attribute", + _characterid: targetId, + name: actualName, + })[0]; + } + function isLegacySheet(targetId) { + const character = getObj("character", targetId); + if (!character) { + return false; + } + return character.sheetEnvironment === "legacy" || character.sheetEnvironment === undefined; + } + function legacyAttributeForSheet(targetId, actualName) { + if (!isLegacySheet(targetId)) { + return undefined; + } + return tryFindLegacyAttribute(targetId, actualName); + } + async function resolveObserverKind(targetId, actualName) { + if (isLegacySheet(targetId)) { + return "attribute"; + } + const computed = await getSheetItem(targetId, actualName, "current"); + const computedMax = await getSheetItem(targetId, actualName, "max"); + if (hasSheetItemValue(computed) || hasSheetItemValue(computedMax)) { + return "computed"; + } + const userAttr = await getSheetItem(targetId, `user.${actualName}`, "current"); + const userMax = await getSheetItem(targetId, `user.${actualName}`, "max"); + if (hasSheetItemValue(userAttr) || hasSheetItemValue(userMax)) { + return "userAttribute"; + } + return "computed"; + } + function isNewAttributeOrUser(kind, state) { + if (kind === "computed") { + return false; + } + return state.priorCurrent === "" && state.priorMax === ""; + } + function sheetItemPath(kind, actualName) { + return kind === "userAttribute" ? `user.${actualName}` : actualName; + } + async function writeSheetItemValue(characterId, kind, actualName, key, value) { + const normalized = normalizeKey(key); + if (!WRITABLE_KEYS.has(normalized)) { + return false; + } + const type = normalized; + const path = sheetItemPath(kind, actualName); + try { + await setSheetItem(characterId, path, value, type, { + allowThrow: true, + createAttr: true, + withWorker: true, + }); + return true; + } + catch { + return false; + } + } + function createObserverAttributeObject(targetId, actualName, kind, state, id = "") { + const snapshot = toSnapshot(targetId, actualName, kind, state, id); + const obj = { + get(key) { + const normalized = normalizeKey(key); + const byKey = { + id: snapshot._id, + _id: snapshot._id, + type: snapshot._type, + _type: snapshot._type, + characterid: snapshot._characterid, + _characterid: snapshot._characterid, + name: snapshot.name, + current: snapshot.current, + max: snapshot.max, + }; + return byKey[normalized] ?? byKey[key]; + }, + set(keyOrProps, value) { + const updates = {}; + if (typeof keyOrProps === "string") { + const normalized = normalizeKey(keyOrProps); + if (WRITABLE_KEYS.has(normalized) && value !== undefined) { + updates[normalized] = value; + } + } + else { + if (keyOrProps.current !== undefined) { + updates.current = keyOrProps.current; + } + if (keyOrProps.max !== undefined) { + updates.max = keyOrProps.max; + } + } + for (const [key, nextValue] of Object.entries(updates)) { + if (nextValue === undefined) { + continue; + } + void writeSheetItemValue(targetId, kind, actualName, key, nextValue).then(ok => { + if (ok) { + snapshot[key] = nextValue; + } + }); + } + return obj; + }, + toJSON() { + return { ...snapshot }; + }, + }; + return obj; + } + function resolveObserverDestroyObj(targetId, actualName, kind) { + if (kind !== "attribute" || !isLegacySheet(targetId)) { + return undefined; + } + return tryFindLegacyAttribute(targetId, actualName); + } + function resolveObserverObj(targetId, actualName, kind, state) { + if (kind === "attribute") { + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + return legacyAttr; + } + } + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + const id = legacyAttr?.get("_id") ?? ""; + return createObserverAttributeObject(targetId, actualName, kind, state, id); + } + function resolveObserverAddObj(targetId, actualName, kind, state) { + if (kind === "attribute") { + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + return legacyAttr; + } + } + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + const id = legacyAttr?.get("_id") ?? ""; + return createObserverAttributeObject(targetId, actualName, kind, state, id); + } + async function captureDeletePriorState(targetId, actualName, kind, priorValues) { + const maxKey = `${actualName}_max`; + let priorCurrent = priorValues[targetId]?.[actualName]; + let priorMax = priorValues[targetId]?.[maxKey]; + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + if (!hasPriorValue$1(priorCurrent)) { + priorCurrent = legacyAttr.get("current"); + } + if (!hasPriorValue$1(priorMax)) { + priorMax = legacyAttr.get("max"); + } + } + else { + const userCurrent = await getSheetItem(targetId, `user.${actualName}`, "current"); + const userMax = await getSheetItem(targetId, `user.${actualName}`, "max"); + const hasUserValues = hasSheetItemValue(userCurrent) || hasSheetItemValue(userMax); + const path = hasUserValues || kind === "userAttribute" + ? `user.${actualName}` + : actualName; + if (!hasPriorValue$1(priorCurrent)) { + priorCurrent = await getSheetItem(targetId, path, "current"); + } + if (!hasPriorValue$1(priorMax)) { + priorMax = await getSheetItem(targetId, path, "max"); + } + if (!hasPriorValue$1(priorCurrent) && hasUserValues) { + priorCurrent = userCurrent; + } + if (!hasPriorValue$1(priorMax) && hasUserValues) { + priorMax = userMax; + } + } + const current = toAttrString(priorCurrent); + const max = toAttrString(priorMax); + return { + current, + max, + priorCurrent: current, + priorMax: max, + }; + } + function logicalAttributeKey(target, actualName) { + return `${target}:${actualName}`; + } + function toActualName(name) { + const isMax = name.endsWith("_max"); + return { + actualName: isMax ? name.slice(0, -4) : name, + isMax, + }; + } + + function buildSetAttributeOptions(overrides = {}) { + const { useWorkers = true } = getConfig() || {}; + return { + noCreate: overrides.noCreate ?? false, + setWithWorker: overrides.setWithWorker ?? useWorkers, + }; + } + function failureKey(target, name) { + return `${target}:${name}`; + } + function collectLogicalGroups(results) { + const groups = new Map(); + for (const target in results) { + for (const name in results[target]) { + const { actualName } = toActualName(name); + const key = logicalAttributeKey(target, actualName); + const existing = groups.get(key); + if (existing) { + existing.keys.push(name); + } + else { + groups.set(key, { target, actualName, keys: [name] }); + } + } + } + return Array.from(groups.values()); + } + function groupHasFailure(group, failed) { + return group.keys.some(name => failed.has(failureKey(group.target, name))); + } + function shouldSkipPairedMaxDelete(target, actualName, isMax, priorValues, results) { + if (!isMax) { + return false; + } + const maxKey = `${actualName}_max`; + const hasCompanionCurrent = Object.hasOwn(results[target], actualName); + if (isLegacySheet(target)) { + return hasCompanionCurrent; + } + // Beacon userAttributes are removed when current is cleared; a follow-up max delete fails. + if (hasCompanionCurrent) { + return true; + } + if (!hasPriorValue(priorValues[target]?.[maxKey])) { + return true; + } + return false; + } + function hasPriorValue(value) { + return value !== undefined && value !== null && value !== ""; + } + async function makeUpdate(operation, results, options) { + const isSetting = operation !== "delattr"; + const errors = []; + const messages = []; + const failed = []; + const failedSet = new Set(); + const { noCreate = false, priorValues = {} } = options || {}; + const setOptions = buildSetAttributeOptions({ noCreate }); + const deleteKinds = new Map(); + const deleteStates = new Map(); + const deleteObserverTargets = new Map(); + if (!isSetting) { + for (const target in results) { + for (const name in results[target]) { + const { actualName } = toActualName(name); + const groupKey = logicalAttributeKey(target, actualName); + if (!deleteKinds.has(groupKey)) { + deleteKinds.set(groupKey, await resolveObserverKind(target, actualName)); + } + if (!deleteStates.has(groupKey)) { + const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); + deleteStates.set(groupKey, await captureDeletePriorState(target, actualName, kind, priorValues)); + } + if (!deleteObserverTargets.has(groupKey)) { + const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); + deleteObserverTargets.set(groupKey, resolveObserverDestroyObj(target, actualName, kind)); + } + } + } + } + for (const target in results) { + for (const name in results[target]) { + const { actualName, isMax } = toActualName(name); + const type = isMax ? "max" : "current"; + const key = failureKey(target, name); + const newValue = results[target][name]; + if (isSetting) { + const value = newValue ?? ""; + try { + const ok = await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); + if (!ok) { + failed.push(key); + failedSet.add(key); + errors.push(`Failed to set attribute '${name}' on target '${target}'.`); + } + } + catch (error) { + failed.push(key); + failedSet.add(key); + errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); + } + } + else { + if (shouldSkipPairedMaxDelete(target, actualName, isMax, priorValues, results)) { + continue; + } + try { + const ok = await libSmartAttributes.deleteAttribute(target, actualName, type); + if (!ok) { + failed.push(key); + failedSet.add(key); + errors.push(`Failed to delete attribute '${actualName}' on target '${target}'.`); + } + } + catch (error) { + failed.push(key); + failedSet.add(key); + errors.push(`Failed to delete attribute '${actualName}' on target '${target}': ${String(error)}`); + } + } + } + } + const groups = collectLogicalGroups(results); + for (const group of groups) { + if (groupHasFailure(group, failedSet)) { + continue; + } + const groupKey = logicalAttributeKey(group.target, group.actualName); + const state = isSetting + ? mergeAttributeState(group.target, group.actualName, priorValues, results, false) + : deleteStates.get(groupKey) ?? mergeAttributeState(group.target, group.actualName, priorValues, results, true); + const kind = isSetting + ? await resolveObserverKind(group.target, group.actualName) + : deleteKinds.get(logicalAttributeKey(group.target, group.actualName)) ?? await resolveObserverKind(group.target, group.actualName); + if (isSetting) { + const prev = toSnapshot(group.target, group.actualName, kind, { + current: state.priorCurrent, + max: state.priorMax, + }); + const obj = resolveObserverObj(group.target, group.actualName, kind, state); + if (isNewAttributeOrUser(kind, state)) { + notifyObservers("add", resolveObserverAddObj(group.target, group.actualName, kind, state)); + } + notifyObservers("change", obj, prev); + } + else { + const obj = deleteObserverTargets.get(groupKey) + ?? resolveObserverObj(group.target, group.actualName, kind, state); + notifyObservers("destroy", obj); + } + } + return { errors, messages, failed }; + } + + // #region Get Attributes + async function getSingleAttribute(target, attributeName) { + const isMax = attributeName.endsWith("_max"); + const type = isMax ? "max" : "current"; + if (isMax) { + attributeName = attributeName.slice(0, -4); // remove '_max' + } + try { + const attribute = await libSmartAttributes.getAttribute(target, attributeName, type); + return attribute; + } + catch { + return undefined; + } + } + async function getAttributes(target, attributeNames) { + const attributes = {}; + if (Array.isArray(attributeNames)) { + for (const name of attributeNames) { + const cleanName = name.replace(/[^a-zA-Z0-9_]/g, ""); + attributes[cleanName] = await getSingleAttribute(target, cleanName); + } + } + else { + for (const name in attributeNames) { + const cleanName = name.replace(/[^a-zA-Z0-9_]/g, ""); + attributes[cleanName] = await getSingleAttribute(target, cleanName); + } + } + return attributes; + } + + function isBeaconSupported() { + try { + const campaign = Campaign(); + return !!campaign.computedSummary; + } + catch { + return false; + } + } + + function cleanValue(value) { + return value.trim().replace(/^['"](.*)['"]$/g, "$1"); + } + function getCharName(targetID) { + const character = getObj("character", targetID); + if (character) { + return character.get("name"); + } + return `ID: ${targetID}`; + } + + // region Command Handlers + async function setattr(changes, target, referenced = [], noCreate = false, _feedback) { + const result = {}; + const errors = []; + const request = createRequestList(referenced, changes, false); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + for (const change of changes) { + const { name, current, max } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Missing attribute ${name} not created for ${characterName}.`); + continue; + } + if (current !== undefined) { + result[name] = current; + } + if (max !== undefined) { + result[`${name}_max`] = max; + } + } + return { + result, + errors, + }; + } + async function modattr(changes, target, referenced, noCreate = false, _feedback) { + const result = {}; + const errors = []; + const currentValues = await getCurrentValues(target, referenced, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + getCharName(target); + for (const change of changes) { + const { name, current, max } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be modified.`); + continue; + } + const asNumber = Number(currentValues[name] ?? 0); + if (isNaN(asNumber)) { + errors.push(`Attribute '${name}' is not number-valued and so cannot be modified.`); + continue; + } + if (current !== undefined) { + result[name] = calculateModifiedValue(asNumber, current); + } + if (max !== undefined) { + result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); + } + } + return { + result, + errors, + }; + } + async function modbattr(changes, target, referenced, noCreate = false, _feedback) { + const result = {}; + const errors = []; + const request = createRequestList(referenced, changes, true); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + getCharName(target); + for (const change of changes) { + const { name, current, max } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be modified.`); + continue; + } + const asNumber = Number(currentValues[name]); + if (isNaN(asNumber)) { + errors.push(`Attribute '${name}' is not number-valued and so cannot be modified.`); + continue; + } + if (current !== undefined) { + result[name] = calculateModifiedValue(asNumber, current); + } + if (max !== undefined) { + result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); + } + const newMax = result[`${name}_max`] ?? currentValues[`${name}_max`]; + if (newMax !== undefined) { + const start = currentValues[name]; + result[name] = calculateBoundValue(result[name] ?? start, newMax); + } + } + return { + result, + errors, + }; + } + async function resetattr(changes, target, referenced, noCreate = false, _feedback) { + const result = {}; + const errors = []; + const request = createRequestList(referenced, changes, true); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + getCharName(target); + for (const change of changes) { + const { name } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be reset.`); + continue; + } + const maxName = `${name}_max`; + if (currentValues[maxName] !== undefined) { + const maxAsNumber = Number(currentValues[maxName]); + if (isNaN(maxAsNumber)) { + errors.push(`Attribute '${maxName}' is not number-valued and so cannot be used to reset '${name}'.`); + continue; + } + result[name] = maxAsNumber; + } + else { + result[name] = 0; + } + } + return { + result, + errors, + }; + } + async function delattr(changes, target, referenced, _, _feedback) { + const result = {}; + for (const change of changes) { + const { name } = change; + if (!name) + continue; + result[name] = undefined; + result[`${name}_max`] = undefined; + } + return { + result, + errors: [], + }; + } + const handlers = { + setattr, + modattr, + modbattr, + resetattr, + delattr, + }; + // #region Helper Functions + function createRequestList(referenced, changes, includeMax = true) { + const requestSet = new Set([...referenced]); + for (const change of changes) { + if (change.name) { + requestSet.add(change.name); + if (includeMax) { + requestSet.add(`${change.name}_max`); + } + } + } + return Array.from(requestSet); + } + function extractUndefinedAttributes(attributes) { + const names = []; + for (const name in attributes) { + if (name.endsWith("_max")) + continue; + if (attributes[name] === undefined) { + names.push(name); + } + } + return names; + } + async function getCurrentValues(target, referenced, changes) { + const queriedAttributes = new Set([...referenced]); + for (const change of changes) { + if (change.name) { + queriedAttributes.add(change.name); + queriedAttributes.add(`${change.name}_max`); + } + } + const attributes = await getAttributes(target, Array.from(queriedAttributes)); + return attributes; + } + function calculateModifiedValue(baseValue, modification) { + const operator = getOperator(modification); + baseValue = Number(baseValue); + if (operator) { + modification = Number(String(modification).substring(1)); + } + else { + modification = Number(modification); + } + if (isNaN(baseValue)) + baseValue = 0; + if (isNaN(modification)) + modification = 0; + return applyCalculation(baseValue, modification, operator); + } + function getOperator(value) { + if (typeof value === "string") { + const match = value.match(/^([+\-*/])/); + if (match) { + return match[1]; + } + } + return; + } + function applyCalculation(baseValue, modification, operator = "+") { + modification = Number(modification); + switch (operator) { + case "+": + return baseValue + modification; + case "-": + return baseValue - modification; + case "*": + return baseValue * modification; + case "/": + return modification !== 0 ? baseValue / modification : baseValue; + default: + return baseValue + modification; + } + } + function calculateBoundValue(currentValue, maxValue) { + currentValue = Number(currentValue); + maxValue = Number(maxValue); + if (isNaN(currentValue)) + currentValue = 0; + if (isNaN(maxValue)) + return currentValue; + return Math.max(Math.min(currentValue, maxValue), 0); + } + + function formatFeedbackValue(value) { + if (value === undefined || value === null || value === "") { + return "(empty)"; + } + return String(value); + } + function formatAttributePart(name, result) { + const hasCurrent = Object.hasOwn(result, name); + const maxKey = `${name}_max`; + const hasMax = Object.hasOwn(result, maxKey); + if (!hasCurrent && !hasMax) { + return null; + } + if (hasCurrent && hasMax) { + return `${name} to ${formatFeedbackValue(result[name])} / ${formatFeedbackValue(result[maxKey])}`; + } + if (hasCurrent) { + return `${name} to ${formatFeedbackValue(result[name])}`; + } + return `${name} to ${formatFeedbackValue(result[maxKey])} (max)`; + } + function formatSettingFeedback(characterName, changes, result) { + const parts = []; + for (const change of changes) { + if (!change.name) + continue; + const part = formatAttributePart(change.name, result); + if (part) { + parts.push(part); + } + } + if (parts.length === 0) { + return null; + } + return `Setting ${parts.join(", ")} for character ${characterName}.`; + } + function formatDeleteFeedback(characterName, changes, result) { + const names = []; + for (const change of changes) { + if (!change.name) + continue; + if (Object.hasOwn(result, change.name)) { + names.push(change.name); + } + } + if (names.length === 0) { + return null; + } + return `Deleting attribute(s) ${names.join(", ")} for character ${characterName}.`; + } + function createFeedbackMessage(characterName, feedback, startingValues, targetValues) { + let message = feedback?.content ?? ""; + // _NAMEJ_: will insert the attribute name. + // _TCURJ_: will insert what you are changing the current value to (or changing by, if you're using --mod or --modb). + // _TMAXJ_: will insert what you are changing the maximum value to (or changing by, if you're using --mod or --modb). + // _CHARNAME_: will insert the character name. + // _CURJ_: will insert the final current value of the attribute, for this character. + // _MAXJ_: will insert the final maximum value of the attribute, for this character. + const targetValueKeys = getChangedAttributeNames(targetValues); + message = message.replace("_CHARNAME_", characterName); + message = message.replace(/_(NAME|TCUR|TMAX|CUR|MAX)(\d+)_/g, (_, key, num) => { + const index = parseInt(num, 10); + const attributeName = targetValueKeys[index]; + if (!attributeName) + return ""; + const sheetCurrent = startingValues[attributeName]; + const sheetMax = startingValues[`${attributeName}_max`]; + const resultCurrent = targetValues[attributeName]; + const resultMax = targetValues[`${attributeName}_max`]; + switch (key) { + case "NAME": + return attributeName; + case "TCUR": + return sheetCurrent !== undefined ? `${sheetCurrent}` : ""; + case "TMAX": + return sheetMax !== undefined ? `${sheetMax}` : ""; + case "CUR": { + const value = resultCurrent ?? sheetCurrent; + return value !== undefined ? `${value}` : ""; + } + case "MAX": { + const value = resultMax ?? sheetMax; + return value !== undefined ? `${value}` : ""; + } + default: + return ""; + } + }); + return message; + } + function getChangedAttributeNames(targetValues) { + const seen = new Set(); + const names = []; + for (const key of Object.keys(targetValues)) { + const name = key.endsWith("_max") ? key.slice(0, -4) : key; + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + } + return names; + } + + var $schema = "./content.schema.json"; + var title = "ChatSetAttr"; + var introduction = "ChatSetAttr is a Roll20 Mod API script that allows users to create, modify, or delete character sheet attributes through chat commands macros. Whether you need to update a single character attribute or make bulk changes across multiple characters, ChatSetAttr provides flexible options to streamline your game management."; + var sections = [ + { + id: "basic-usage", + title: "Basic Usage", + blocks: [ + { + type: "paragraph", + text: "The script provides several command formats:" + }, + { + type: "unorderedList", + items: [ + "`!setattr [--options]` - Create or modify attributes", + "`!modattr [--options]` - Shortcut for `!setattr --mod` (adds to existing values)", + "`!modbattr [--options]` - Shortcut for `!setattr --modb` (adds to values with bounds)", + "`!resetattr [--options]` - Shortcut for `!setattr --reset` (resets to max values)", + "`!delattr [--options]` - Delete attributes" + ] + }, + { + type: "paragraph", + text: "Each command requires a target selection option and one or more attributes to modify." + }, + { + type: "paragraph", + text: "**Basic structure:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2" + ] + } + ] + }, + { + id: "available-commands", + title: "Available Commands", + subsections: [ + { + id: "setattr", + title: "!setattr", + blocks: [ + { + type: "paragraph", + text: "Creates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless `--nocreate` is specified)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|25|50 --hp_temp|8" + ] + }, + { + type: "paragraph", + text: "This would set `hp` to 25, `hp_max` to 50, `hp_temp` to 8." + } + ] + }, + { + id: "modattr", + title: "!modattr", + blocks: [ + { + type: "paragraph", + text: "Adds to existing attribute values (works only with numeric values). Shorthand for `!setattr --mod`." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!modattr --sel --hp_temp|-5 --hp|6" + ] + }, + { + type: "paragraph", + text: "This subtracts 5 from `hp_temp` and adds 6 to `hp`." + } + ] + }, + { + id: "modbattr", + title: "!modbattr", + blocks: [ + { + type: "paragraph", + text: "Adds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for `!setattr --modb`." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!modbattr --sel --hp_temp|-5 --hp|25" + ] + }, + { + type: "paragraph", + text: "This subtracts 5 from `hp_temp` but won't reduce it below 0 and increase `hp` by 25, but won't increase it above `mp_xp`." + } + ] + }, + { + id: "resetattr", + title: "!resetattr", + blocks: [ + { + type: "paragraph", + text: "Resets attributes to their maximum value. Shorthand for `!setattr --reset`." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!resetattr --sel --hp" + ] + }, + { + type: "paragraph", + text: "This resets `hp` to its maximum value." + } + ] + }, + { + id: "delattr", + title: "!delattr", + blocks: [ + { + type: "paragraph", + text: "Deletes the specified attributes." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!delattr --sel --hp --hp_temp" + ] + }, + { + type: "paragraph", + text: "This removes the `hp` and `hp_temp` attributes." + } + ] + } + ] + }, + { + id: "beacon-computed-values", + title: "Beacon Computed Values", + blocks: [ + { + type: "paragraph", + text: "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try." + }, + { + type: "paragraph", + text: "Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message." + }, + { + type: "paragraph", + text: "For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with `user.` like `user.spellpoints`. They function like attributes and can be created, removed, set, reset, and modified as desired." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --spellpoints|18" + ] + }, + { + type: "paragraph", + text: "This will create the `user.spellpoints` User Attribute, which can be referenced as either `@{selected|user.spellpoints}` or `@{selected|spellpoints}` and operates like an attribute." + } + ] + }, + { + id: "target-selection", + title: "Target Selection", + blocks: [ + { + type: "paragraph", + text: "One of these options must be specified to determine which characters will be affected:" + } + ], + subsections: [ + { + id: "all", + title: "--all", + blocks: [ + { + type: "paragraph", + text: "Affects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!resetattr --all --hp" + ] + } + ] + }, + { + id: "allgm", + title: "--allgm", + blocks: [ + { + type: "paragraph", + text: "Affects all characters without player controllers (typically NPCs). **GM only**." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --allgm --reset --hp" + ] + } + ] + }, + { + id: "allplayers", + title: "--allplayers", + blocks: [ + { + type: "paragraph", + text: "Affects all characters with player controllers (typically PCs)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --allplayers --mod --hp|-15" + ] + } + ] + }, + { + id: "charid", + title: "--charid", + blocks: [ + { + type: "paragraph", + text: "Affects characters with the specified character IDs. Non-GM players can only affect characters they control. Multiple IDs must be separated by a comma." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --charid , --hp|150" + ] + } + ] + }, + { + id: "name", + title: "--name", + blocks: [ + { + type: "paragraph", + text: "Affects characters with the specified names. Non-GM players can only affect characters they control. Multiple character names must be separated by a comma." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --name Gandalf, Frodo Baggins --party|\"Fellowship of the Ring\"" + ] + } + ] + }, + { + id: "sel", + title: "--sel", + blocks: [ + { + type: "paragraph", + text: "Affects characters represented by currently selected tokens." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|25 --hp_temp|8" + ] + } + ] + }, + { + id: "sel-party", + title: "--sel-party", + blocks: [ + { + type: "paragraph", + text: "Affects only party characters represented by currently selected tokens (characters with `inParty` set to true)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-party --inspiration|1" + ] + } + ] + }, + { + id: "sel-noparty", + title: "--sel-noparty", + blocks: [ + { + type: "paragraph", + text: "Affects only non-party characters represented by currently selected tokens (characters with `inParty` set to false or not set)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-noparty --npc_status|\"Hostile\"" + ] + } + ] + }, + { + id: "party", + title: "--party", + blocks: [ + { + type: "paragraph", + text: "Affects all characters marked as party members (characters with `inParty` set to true). **GM only by default**, but can be enabled for players with configuration." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --party --rest_complete|1" + ] + } + ] + } + ] + }, + { + id: "attribute-syntax", + title: "Attribute Syntax", + blocks: [ + { + type: "paragraph", + text: "The syntax for specifying attributes is:" + }, + { + type: "codeBlock", + lines: [ + "--attributeName|currentValue|maxValue" + ] + }, + { + type: "unorderedList", + items: [ + "`attributeName` is the name of the attribute to modify", + "`currentValue` is the value to set (optional for some commands)", + "`maxValue` is the maximum value to set (optional)" + ] + } + ], + subsections: [ + { + id: "examples", + title: "Examples:", + blocks: [ + { + type: "orderedList", + items: [ + { + text: "Set current value only:", + codeBlock: { + lines: [ + "--strength|15" + ] + } + }, + { + text: "Set both current and maximum values:", + codeBlock: { + lines: [ + "--hp|27|35" + ] + } + }, + { + text: "Set only the maximum value (leave current unchanged):", + codeBlock: { + lines: [ + "--hp||50" + ] + } + }, + { + text: "Create empty attribute or set to empty:", + codeBlock: { + lines: [ + "--notes|" + ] + } + }, + { + text: "Use `#` instead of `|` (useful in roll queries):", + codeBlock: { + lines: [ + "--strength#15" + ] + } + } + ] + } + ] + } + ] + }, + { + id: "multiline-commands", + title: "Multiline Commands", + blocks: [ + { + type: "paragraph", + text: "Long commands can be spread across multiple lines for readability by wrapping the entire body of options in double curly braces `{{ }}`. When Roll20 sends a multiline message to the API, ChatSetAttr strips the line breaks and the surrounding braces before parsing, so a `{{ }}`-wrapped command behaves identically to the same command written on a single line." + }, + { + type: "paragraph", + text: "**Single-line form:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --replace --repeating_classfeature_-create_name|Some Feature --repeating_classfeature_-create_content|Some long content to set in the body of this class feature. --repeating_classfeature_-create_content_toggle|1" + ] + }, + { + type: "paragraph", + text: "**Equivalent multiline form:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr {{", + "--sel", + "--replace", + "--repeating_classfeature_-create_name|Some Feature", + "--repeating_classfeature_-create_content|Some long content to set in the body of this class feature.", + "--repeating_classfeature_-create_content_toggle|1", + "}}" + ] + }, + { + type: "paragraph", + text: "Escaped braces `\\{` and `\\}` inside the body are unescaped to literal `{` and `}` after the wrapper is removed, so you can still include braces in attribute values." + }, + { + type: "note", + text: "**Whitespace limitation:** The closing `}}` must be preceded by whitespace (a space or a line break) for the block to be unwrapped. Placing each option on its own line satisfies this automatically, but writing the last value directly against the closing braces (for example `...value}}`) will not be recognized as a multiline command. Keep the closing `}}` on its own line or leave a space before it.", + emphasis: true + } + ] + }, + { + id: "modifier-options", + title: "Modifier Options", + blocks: [ + { + type: "paragraph", + text: "These options change how attributes are processed:" + } + ], + subsections: [ + { + id: "mod", + title: "--mod", + blocks: [ + { + type: "paragraph", + text: "See `!modattr` command." + } + ] + }, + { + id: "modb", + title: "--modb", + blocks: [ + { + type: "paragraph", + text: "See `!modbattr` command." + } + ] + }, + { + id: "reset", + title: "--reset", + blocks: [ + { + type: "paragraph", + text: "See `!resetattr` command." + } + ] + }, + { + id: "nocreate", + title: "--nocreate", + blocks: [ + { + type: "paragraph", + text: "Prevents creation of new attributes, only updates existing ones." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --nocreate --perception|20 --hp|15" + ] + }, + { + type: "paragraph", + text: "This will only update `perception` or `hp` if it already exists." + } + ] + }, + { + id: "evaluate", + title: "--evaluate", + blocks: [ + { + type: "paragraph", + text: "Evaluates JavaScript expressions in attribute values. **GM only by default**." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --evaluate --hp|2 * 3" + ] + }, + { + type: "paragraph", + text: "This will set the `hp` attribute to 6." + } + ] + }, + { + id: "replace", + title: "--replace", + blocks: [ + { + type: "paragraph", + text: "Replaces special characters to prevent Roll20 from evaluating them:" + }, + { + type: "unorderedList", + items: [ + "< becomes [", + "> becomes ]", + "~ becomes -", + "; becomes ?", + "` becomes @" + ] + }, + { + type: "paragraph", + text: "Also supports \\lbrak, \\rbrak, \\n, \\at, and \\ques for [, ], newline, @, and ?." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"" + ] + }, + { + type: "paragraph", + text: "This stores \"Roll [[1d6]] to succeed\" without evaluating the roll." + } + ] + } + ] + }, + { + id: "output-control-options", + title: "Output Control Options", + blocks: [ + { + type: "paragraph", + text: "These options control the feedback messages generated by the script:" + } + ], + subsections: [ + { + id: "silent", + title: "--silent", + blocks: [ + { + type: "paragraph", + text: "Suppresses normal output messages (error messages will still appear)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --silent --stealth|20" + ] + } + ] + }, + { + id: "mute", + title: "--mute", + blocks: [ + { + type: "paragraph", + text: "Suppresses all output messages, including errors." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --mute --nocreate --new_value|42" + ] + } + ] + }, + { + id: "fb-public", + title: "--fb-public", + blocks: [ + { + type: "paragraph", + text: "Sends output publicly to the chat instead of whispering to the command sender." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-public --hp|25|25 --status|\"Healed\"" + ] + } + ] + }, + { + id: "fb-from", + title: "--fb-from ", + blocks: [ + { + type: "paragraph", + text: "Changes the name of the sender for output messages (default is \"ChatSetAttr\")." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-from \"Healing Potion\" --hp|25" + ] + } + ] + }, + { + id: "fb-header", + title: "--fb-header ", + blocks: [ + { + type: "paragraph", + text: "Customizes the header of the output message." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --evaluate --fb-header \"Combat Effects Applied\" --status|\"Poisoned\" --hp|%hp%-5" + ] + } + ] + }, + { + id: "fb-content", + title: "--fb-content ", + blocks: [ + { + type: "paragraph", + text: "Customizes the content of the output message." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-content \"Increasing Hitpoints\" --hp|10" + ] + } + ] + }, + { + id: "special-placeholders", + title: "Special Placeholders", + blocks: [ + { + type: "paragraph", + text: "For use in `--fb-header` and `--fb-content`:" + }, + { + type: "unorderedList", + items: [ + "`_NAMEJ_` - Name of the Jth attribute being changed", + "`_TCURJ_` - Target current value of the Jth attribute", + "`_TMAXJ_` - Target maximum value of the Jth attribute" + ] + }, + { + type: "paragraph", + text: "For use in `--fb-content` only:" + }, + { + type: "unorderedList", + items: [ + "`_CHARNAME_` - Name of the character", + "`_CURJ_` - Final current value of the Jth attribute", + "`_MAXJ_` - Final maximum value of the Jth attribute" + ] + }, + { + type: "paragraph", + text: "**Important:** The Jth index starts with 0 at the first item." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-header \"Healing Effects\" --fb-content \"_CHARNAME_ healed by _CUR0_ hitpoints --hp|10" + ] + } + ] + } + ] + }, + { + id: "inline-roll-integration", + title: "Inline Roll Integration", + blocks: [ + { + type: "paragraph", + text: "ChatSetAttr can be used within roll templates or combined with inline rolls:" + } + ], + subsections: [ + { + id: "within-roll-templates", + title: "Within Roll Templates", + blocks: [ + { + type: "paragraph", + text: "Place the command between roll template properties and end it with `!!!`:" + }, + { + type: "codeBlock", + lines: [ + "&{template:default} {{name=Fireball Damage}} !setattr --mod --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}" + ] + } + ] + }, + { + id: "using-inline-rolls-in-values", + title: "Using Inline Rolls in Values", + blocks: [ + { + type: "paragraph", + text: "Inline rolls can be used for attribute values:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|[[2d6+5]]" + ] + } + ] + }, + { + id: "roll-queries", + title: "Roll Queries", + blocks: [ + { + type: "paragraph", + text: "Roll queries can determine attribute values:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|?{Set strength to what value?|100}" + ] + } + ] + } + ] + }, + { + id: "repeating-section-support", + title: "Repeating Section Support", + blocks: [ + { + type: "paragraph", + text: "ChatSetAttr supports working with repeating sections:" + } + ], + subsections: [ + { + id: "creating-new-repeating-items", + title: "Creating New Repeating Items", + blocks: [ + { + type: "paragraph", + text: "Use `CREATE` to create a new row in a repeating section:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_CREATE_itemname|\"Magic Sword\" --repeating_inventory_CREATE_itemweight|2" + ] + } + ] + }, + { + id: "modifying-existing-repeating-items", + title: "Modifying Existing Repeating Items", + blocks: [ + { + type: "paragraph", + text: "Access by row ID:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_ID_itemname|\"Enchanted Magic Sword\"" + ] + }, + { + type: "paragraph", + text: "Access by index (starts at 0):" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_$0_itemname|\"First Item\"" + ] + } + ] + }, + { + id: "deleting-repeating-rows", + title: "Deleting Repeating Rows", + blocks: [ + { + type: "paragraph", + text: "Delete by row ID:" + }, + { + type: "codeBlock", + lines: [ + "!delattr --sel --repeating_inventory_ID" + ] + }, + { + type: "paragraph", + text: "Delete by index:" + }, + { + type: "codeBlock", + lines: [ + "!delattr --sel --repeating_inventory_$0" + ] + }, + { + type: "note", + text: "repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.", + emphasis: true + } + ] + } + ] + }, + { + id: "special-value-expressions", + title: "Special Value Expressions", + subsections: [ + { + id: "attribute-references", + title: "Attribute References", + blocks: [ + { + type: "paragraph", + text: "Reference other attribute values using `%attribute_name%`:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --evaluate --temp_hp|%hp% / 2" + ] + } + ] + }, + { + id: "resetting-to-maximum", + title: "Resetting to Maximum", + blocks: [ + { + type: "paragraph", + text: "Reset an attribute to its maximum value:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|%hp_max%" + ] + } + ] + } + ] + }, + { + id: "global-configuration", + title: "Global Configuration", + blocks: [ + { + type: "paragraph", + text: "The script has four global configuration options that can be toggled with `!setattr-config`:" + } + ], + subsections: [ + { + id: "players-can-modify", + title: "--players-can-modify", + blocks: [ + { + type: "paragraph", + text: "Allows players to modify attributes on characters they don't control." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --players-can-modify" + ] + } + ] + }, + { + id: "players-can-evaluate", + title: "--players-can-evaluate", + blocks: [ + { + type: "paragraph", + text: "Allows players to use the `--evaluate` option." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --players-can-evaluate" + ] + } + ] + }, + { + id: "players-can-target-party", + title: "--players-can-target-party", + blocks: [ + { + type: "paragraph", + text: "Allows players to use the `--party` target option. **GM only by default**." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --players-can-target-party" + ] + } + ] + }, + { + id: "use-workers", + title: "--use-workers", + blocks: [ + { + type: "paragraph", + text: "Toggles whether the script triggers sheet workers when setting attributes." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --use-workers" + ] + } + ] + } + ] + }, + { + id: "complete-examples", + title: "Complete Examples", + subsections: [ + { + id: "basic-combat-example", + title: "Basic Combat Example", + blocks: [ + { + type: "paragraph", + text: "Reduce a character's HP and status after taking damage:" + }, + { + type: "codeBlock", + lines: [ + "!modattr --sel --evaluate --hp|-15 --fb-header \"Combat Result\" --fb-content \"_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!\"" + ] + } + ] + }, + { + id: "leveling-up-a-character", + title: "Leveling Up a Character", + blocks: [ + { + type: "paragraph", + text: "Update multiple stats when a character gains a level:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from \"Level Up\" --fb-header \"Character Advanced\" --fb-public" + ] + } + ] + }, + { + id: "create-new-item-in-inventory", + title: "Create New Item in Inventory", + blocks: [ + { + type: "paragraph", + text: "Add a new item to a character's inventory:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_-CREATE_itemname|\"Healing Potion\" --repeating_inventory_-CREATE_itemcount|3 --repeating_inventory_-CREATE_itemweight|0.5 --repeating_inventory_-CREATE_itemcontent|\"Restores 2d8+2 hit points when consumed\"" + ] + } + ] + }, + { + id: "apply-status-effects-during-combat", + title: "Apply Status Effects During Combat", + blocks: [ + { + type: "paragraph", + text: "Apply a debuff to selected enemies in the middle of combat:" + }, + { + type: "codeBlock", + lines: [ + "&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|\"Restrained\"!!! {{duration=1d4 rounds}}" + ] + } + ] + }, + { + id: "party-management-examples", + title: "Party Management Examples", + blocks: [ + { + type: "paragraph", + text: "Give inspiration to all party members after a great roleplay moment:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --party --inspiration|1 --fb-public --fb-header \"Inspiration Awarded\" --fb-content \"All party members receive inspiration for excellent roleplay!\"" + ] + }, + { + type: "paragraph", + text: "Apply a long rest to only party characters among selected tokens:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header \"Long Rest Complete\"" + ] + }, + { + type: "paragraph", + text: "Set hostile status for non-party characters among selected tokens:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-noparty --attitude|\"Hostile\" --fb-from \"DM\" --fb-content \"Enemies are now hostile!\"" + ] + } + ] + } + ] + }, + { + id: "for-developers", + title: "For Developers", + subsections: [ + { + id: "registering-observers", + title: "Registering Observers", + blocks: [ + { + type: "paragraph", + text: "If you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:" + }, + { + type: "codeBlock", + lines: [ + "ChatSetAttr.registerObserver(event, observer);" + ] + }, + { + type: "paragraph", + text: "Where `event` is one of:" + }, + { + type: "unorderedList", + items: [ + "`\"add\"` - Called when attributes are created", + "`\"change\"` - Called when attributes are modified", + "`\"destroy\"` - Called when attributes are deleted" + ] + }, + { + type: "paragraph", + text: "And `observer` is an event handler function similar to Roll20's built-in event handlers." + }, + { + type: "paragraph", + text: "This allows your scripts to react to changes made by ChatSetAttr the same way they would react to changes made directly by Roll20's interface." + } + ] + } + ] + } + ]; + var helpContent = { + $schema: $schema, + title: title, + introduction: introduction, + sections: sections + }; + + function loadHelpDocument() { + return helpContent; + } + + const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`)/g; + function renderInlineHtml(text) { + const parts = []; + let lastIndex = 0; + let match; + INLINE_PATTERN.lastIndex = 0; + while ((match = INLINE_PATTERN.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(escapeHtml(text.slice(lastIndex, match.index))); + } + const token = match[0]; + if (token.startsWith("**")) { + parts.push(`${escapeHtml(token.slice(2, -2))}`); + } + else { + parts.push(`${escapeHtml(token.slice(1, -1))}`); + } + lastIndex = match.index + token.length; + } + if (lastIndex < text.length) { + parts.push(escapeHtml(text.slice(lastIndex))); + } + return new SafeHtml(parts.join("")); + } + function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + function joinCodeLines(lines) { + return lines.join("\n"); + } + + function concatHtml(...parts) { + return new SafeHtml(parts.map(part => part.html).join("")); + } + function renderBlocks(blocks) { + if (!blocks) + return []; + const parts = []; + for (const block of blocks) { + switch (block.type) { + case "paragraph": + parts.push(h("p", {}, renderInlineHtml(block.text))); + break; + case "codeBlock": + parts.push(h("pre", {}, h("code", {}, joinCodeLines(block.lines)))); + break; + case "unorderedList": + parts.push(h("ul", {}, ...block.items.map(item => h("li", {}, renderInlineHtml(item))))); + break; + case "orderedList": + parts.push(h("ol", {}, ...block.items.map(item => { + const children = [renderInlineHtml(item.text)]; + if (item.codeBlock) { + children.push(h("pre", {}, h("code", {}, joinCodeLines(item.codeBlock.lines)))); + } + return h("li", {}, ...children); + }))); + break; + case "note": + parts.push(block.emphasis + ? h("p", {}, h("em", {}, h("strong", {}, "Note:"), " ", renderInlineHtml(block.text))) + : h("p", {}, renderInlineHtml(block.text))); + break; + } + } + return parts; + } + function renderSubsection(subsection) { + return concatHtml(h("h3", {}, subsection.title), ...renderBlocks(subsection.blocks)); + } + function renderSection(section) { + return concatHtml(h("h2", { id: section.id }, section.title), ...renderBlocks(section.blocks), ...(section.subsections?.map(renderSubsection) ?? [])); + } + function renderTableOfContents(doc, handoutID) { + return h("ol", {}, ...doc.sections.map(section => h("li", {}, h("a", { + href: `http://journal.roll20.net/handout/${handoutID}/#${section.title.replace(/\s+/g, "%20")}`, + }, section.title)))); + } + function renderHelpHtml(doc, handoutID) { + return concatHtml(h("h1", {}, doc.title), h("p", {}, doc.introduction), h("h2", {}, "Table of Contents"), renderTableOfContents(doc, handoutID), ...doc.sections.map(section => renderSection(section))).html; + } + + function createHelpHandout(handoutID) { + return renderHelpHtml(loadHelpDocument(), handoutID); + } + + var updatedAt = 1783723898022; + var contentRevision = { + updatedAt: updatedAt + }; + + const revision = contentRevision; + function getBundledHelpContentUpdatedAt() { + return revision.updatedAt; + } + + const HELP_COMMAND = "!setattr-help"; + const HELP_HANDOUT_NAME = "ChatSetAttr Help"; + function checkHelpMessage(msg) { + return msg.trim().toLowerCase().startsWith(HELP_COMMAND); + } + function findHelpHandout() { + return findObjs({ + _type: "handout", + name: HELP_HANDOUT_NAME, + })[0]; + } + function applyHelpContentToHandout(handout) { + const helpContent = createHelpHandout(handout.id); + const bundledAt = getBundledHelpContentUpdatedAt(); + handout.set({ + inplayerjournals: "all", + notes: helpContent, + }); + setConfig({ helpContentUpdatedAt: bundledAt }); + } + function handleHelpCommand() { + let handout = findHelpHandout(); + if (!handout) { + handout = createObj("handout", { + name: HELP_HANDOUT_NAME, + }); + } + applyHelpContentToHandout(handout); + } + function syncHelpHandoutOnStartup() { + const handout = findHelpHandout(); + if (!handout) { + return; + } + const bundledAt = getBundledHelpContentUpdatedAt(); + const stateAt = getConfig().helpContentUpdatedAt; + if (stateAt >= bundledAt) { + return; + } + applyHelpContentToHandout(handout); + } + + function inlineRollValue(roll) { + const tableItems = roll.results.rolls.reduce((names, subRoll) => { + const tableSubRoll = subRoll; + if (!Object.prototype.hasOwnProperty.call(tableSubRoll, "table")) { + return names; + } + const subNames = (tableSubRoll.results ?? []) + .map(result => result.tableItem?.name ?? "") + .filter(Boolean); + if (subNames.length) { + names.push(subNames.join(", ")); + } + return names; + }, []); + const tableText = tableItems.filter(Boolean).join(", "); + return (tableText.length && tableText) || roll.results.total || 0; + } + function normalizeTemplateRollProperties(content) { + return content + .replace(/\{\{[^}[\]]+=\$?\[\[(\d+)\]\].*?\}\}/g, (_, index) => `$[[${index}]]`) + .replace(/\{\{[^}=]+=([^}]+)\}\}/g, (_, value) => value.trim()); + } + function processInlinerolls(msg) { + if (!msg.inlinerolls?.length) { + return msg.content; + } + const values = msg.inlinerolls.map(roll => String(inlineRollValue(roll))); + return values.reduce((content, value, index) => content.replace(`$[[${index}]]`, value), msg.content); + } + + // #region Commands + const COMMAND_TYPE = [ + "setattr", + "modattr", + "modbattr", + "resetattr", + "delattr" + ]; + function isCommand(command) { + return COMMAND_TYPE.includes(command); + } + // #region Command Options + const COMMAND_OPTIONS = [ + "mod", + "modb", + "reset" + ]; + const OVERRIDE_DICTIONARY = { + "mod": "modattr", + "modb": "modbattr", + "reset": "resetattr", + }; + function isCommandOption(option) { + return COMMAND_OPTIONS.includes(option); + } + // #region Targets + const TARGETS = [ + "all", + "allgm", + "allplayers", + "charid", + "name", + "sel", + "sel-noparty", + "sel-party", + "party", + ]; + // #region Feedback + const FEEDBACK_OPTIONS = [ + "fb-public", + "fb-from", + "fb-header", + "fb-content", + ]; + function isFeedbackOption(option) { + for (const fbOption of FEEDBACK_OPTIONS) { + if (option.startsWith(fbOption)) + return true; + } + return false; + } + function extractFeedbackKey(option) { + if (option === "fb-public") + return "public"; + if (option === "fb-from") + return "from"; + if (option === "fb-header") + return "header"; + if (option === "fb-content") + return "content"; + return false; + } + // #region Options + const OPTIONS = [ + "nocreate", + "evaluate", + "replace", + "silent", + "mute", + ]; + function isOption(option) { + return OPTIONS.includes(option); + } + // #region Alias Characters + const ALIAS_CHARACTERS = { + "<": "[", + ">": "]", + "~": "-", + ";": "?", + "`": "@", + }; + + // #region Inline Message Extraction and Validation + function validateMessage(content) { + for (const command of COMMAND_TYPE) { + const messageCommand = content.split(" ")[0]; + if (messageCommand === `!${command}`) { + return true; + } + } + return false; + } + function extractMessageFromRollTemplate(msg) { + for (const command of COMMAND_TYPE) { + if (msg.content.includes(command)) { + const regex = new RegExp(`(!${command}.*?)!!!`, "gi"); + const match = regex.exec(msg.content); + if (match) + return match[1].trim(); + } + } + return false; + } + // Roll20 delivers multiline commands wrapped in `{{ ... }}` with HTML line + // breaks (`
`). This mirrors ChatSetAttr 1.10's `parseOpts` preprocessing so + // a `{{ }}`-wrapped multiline command parses identically to its flat form. + function normalizeMultilineCommand(content) { + return content + .replace(//g, "") + .replace(/\s+$/g, "") + .replace(/\s*{{((?:.|\n)*)\s+}}$/, " $1") + .replace(/\\([{}])/g, "$1"); + } + // #region Message Parsing + function extractOperation(parts) { + if (parts.length === 0) { + log("Empty Command."); + return; + } + const commandPart = parts.shift(); + const tokens = commandPart.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) { + log("Empty Command."); + return; + } + if (!tokens[0].startsWith("!")) { + log("Invalid Command."); + return; + } + const command = tokens[0].slice(1); + if (!isCommand(command)) { + log("Invalid Command."); + return; + } + if (tokens.length > 1) { + parts.unshift(tokens.slice(1).join(" ")); + } + return command; + } + function extractReferences(value) { + if (typeof value !== "string") + return []; + const matches = value.matchAll(/%[a-zA-Z0-9_]+%/g); + return Array.from(matches, m => m[0]); + } + function splitMessage(content) { + const split = content.split("--").map(part => part.trim()); + return split; + } + function includesATarget(part) { + if (part.includes("|") || part.includes("#")) + return false; + [part] = part.split(" ").map(p => p.trim()); + for (const target of TARGETS) { + const isMatch = part.toLowerCase() === target.toLowerCase(); + if (isMatch) + return true; + } + return false; + } + function parseMessage(content) { + content = normalizeMultilineCommand(content); + const parts = splitMessage(content); + let operation = extractOperation(parts); + if (!operation) { + return; + } + const targeting = []; + const options = {}; + const changes = []; + const references = []; + const feedback = { public: false }; + for (const part of parts) { + if (isCommandOption(part)) { + operation = OVERRIDE_DICTIONARY[part]; + } + else if (isOption(part)) { + options[part] = true; + } + else if (includesATarget(part)) { + targeting.push(part); + } + else if (isFeedbackOption(part)) { + const [key, ...valueParts] = part.split(" "); + const value = valueParts.join(" "); + const feedbackKey = extractFeedbackKey(key); + if (!feedbackKey) + continue; + if (feedbackKey === "public") { + feedback.public = true; + } + else { + feedback[feedbackKey] = cleanValue(value); + } + } + else if (part.includes("|") || part.includes("#")) { + const split = part.split(/[|#]/g).map(p => p.trim()); + const [attrName, attrCurrent, attrMax] = split; + if (!attrName && !attrCurrent && !attrMax) { + continue; + } + const attribute = {}; + if (attrName) + attribute.name = attrName; + if (attrCurrent) + attribute.current = cleanValue(attrCurrent); + if (attrMax) + attribute.max = cleanValue(attrMax); + changes.push(attribute); + const currentMatches = extractReferences(attrCurrent); + const maxMatches = extractReferences(attrMax); + references.push(...currentMatches, ...maxMatches); + } + else { + const suspectedAttribute = part.replace(/[^-0-9A-Za-z_$]/g, ""); + if (!suspectedAttribute) + continue; + changes.push({ name: suspectedAttribute }); + } + } + return { + operation, + options, + targeting, + changes, + references, + feedback, + }; + } + + const REPEATING_INDEX_TOKEN = /^\$(\d+)$/i; + const REPEATING_CREATE_TOKEN = /^CREATE$/i; + const REPEATING_DASH_CREATE_TOKEN = /^-CREATE$/i; + function isRepeatingCreateToken(token) { + return REPEATING_CREATE_TOKEN.test(token) || REPEATING_DASH_CREATE_TOKEN.test(token); + } + function parseRepeatingIdentifierToken(token) { + if (!token) + return null; + const indexMatch = token.match(REPEATING_INDEX_TOKEN); + if (indexMatch) { + return { kind: "index", index: Number(indexMatch[1]) }; + } + if (isRepeatingCreateToken(token)) { + return { kind: "create" }; + } + return { kind: "rowId", rowId: token }; + } + function isRepeatingRowIdToken(token) { + const parsed = parseRepeatingIdentifierToken(token); + return parsed?.kind === "rowId"; + } + function resolveRowIdInRepOrder(repOrder, rowId) { + const rowIdLo = rowId.toLowerCase(); + const index = repOrder.findIndex(id => id.toLowerCase() === rowIdLo); + if (index === -1) + return null; + return repOrder[index]; + } + function parseRepeatingRowDeleteTarget(name) { + if (extractRepeatingParts(name)) { + return null; + } + const parts = name.split("_"); + if (parts.length !== 3) { + return null; + } + const [repeating, section, identifierToken] = parts; + if (repeating !== "repeating" || !section || !identifierToken) { + return null; + } + const parsed = parseRepeatingIdentifierToken(identifierToken); + if (!parsed || parsed.kind === "create") { + return null; + } + const sectionPrefix = `repeating_${section}`; + if (parsed.kind === "index") { + return { sectionPrefix, rowIndex: parsed.index }; + } + return { sectionPrefix, rowId: parsed.rowId }; + } + function getSectionFromRepeatingPrefix(sectionPrefix) { + const match = sectionPrefix.match(/^repeating_(.+)$/); + return match ? match[1] : null; + } + function resolveRepeatingRowId(target, repOrder) { + if (target.rowIndex !== undefined) { + if (target.rowIndex < 0 || target.rowIndex >= repOrder.length) { + return null; + } + return repOrder[target.rowIndex]; + } + if (target.rowId) { + return resolveRowIdInRepOrder(repOrder, target.rowId); + } + return null; + } + function findRepeatingRowAttributeNames(characterID, sectionPrefix, rowId) { + const prefix = `${sectionPrefix}_${rowId}_`.toUpperCase(); + const attributes = findObjs({ + _type: "attribute", + _characterid: characterID, + }); + const names = []; + for (const attribute of attributes) { + const name = attribute.get("name"); + if (typeof name !== "string") + continue; + if (name.toUpperCase().startsWith(prefix)) { + names.push(name); + } + } + return names; + } + function expandRepeatingRowDeletes(characterID, changes, repOrders, errors, characterName) { + const result = []; + for (const change of changes) { + if (!change.name) + continue; + const target = parseRepeatingRowDeleteTarget(change.name); + if (!target) { + result.push(change); + continue; + } + const section = getSectionFromRepeatingPrefix(target.sectionPrefix); + if (!section) { + result.push(change); + continue; + } + const repOrder = repOrders[section] || []; + const resolvedRowId = resolveRepeatingRowId(target, repOrder); + if (!resolvedRowId) { + if (target.rowIndex !== undefined) { + errors.push(`Repeating row number ${target.rowIndex} invalid for character ${characterName} and repeating section ${target.sectionPrefix}.`); + } + else { + errors.push(`Repeating row id ${target.rowId} invalid for character ${characterName} and repeating section ${target.sectionPrefix}.`); + } + continue; + } + const fieldNames = findRepeatingRowAttributeNames(characterID, target.sectionPrefix, resolvedRowId); + for (const name of fieldNames) { + result.push({ name }); + } + } + return result; + } + function extractRepeatingParts(attributeName) { + const [repeating, section, identifier, ...fieldParts] = attributeName.split("_"); + if (repeating !== "repeating") { + return null; + } + const field = fieldParts.join("_"); + if (!section || !identifier || !field) { + return null; + } + return { + section, + identifier, + field + }; + } + function hasCreateIdentifier(attributeName) { + const parts = extractRepeatingParts(attributeName); + if (parts) { + return isRepeatingCreateToken(parts.identifier); + } + return isRepeatingCreateToken(attributeName); + } + function hasIndexIdentifier(attributeName) { + const parts = extractRepeatingParts(attributeName); + if (!parts) + return false; + return REPEATING_INDEX_TOKEN.test(parts.identifier); + } + function convertRepOrderToArray(repOrder) { + return repOrder.split(",").map(id => id.trim()).filter(Boolean); + } + function discoverRowIds(characterID, section) { + const rowIds = new Set(); + const attributes = findObjs({ + _type: "attribute", + _characterid: characterID, + }); + for (const attribute of attributes) { + const name = attribute.get("name"); + if (typeof name !== "string") + continue; + const parts = name.split("_"); + if (parts.length < 4) + continue; + if (parts[0] !== "repeating" || parts[1] !== section) + continue; + const identifier = parts[2]; + if (isRepeatingRowIdToken(identifier)) { + rowIds.add(identifier); + } + } + return Array.from(rowIds); + } + function mergeRepOrder(storedOrder, discoveredIds) { + const discoveredSet = new Set(discoveredIds); + const ordered = storedOrder.filter(id => discoveredSet.has(id)); + for (const id of discoveredIds) { + if (!ordered.includes(id)) { + ordered.push(id); + } + } + return ordered; + } + async function getRepOrderForSection(characterID, section) { + const repOrderAttribute = `_reporder_repeating_${section}`; + const repOrder = await libSmartAttributes.getAttribute(characterID, repOrderAttribute); + return repOrder; + } + function getAllSectionNames(attributes) { + const sectionNames = new Set(); + for (const attr of attributes) { + if (!attr.name) + continue; + const parts = extractRepeatingParts(attr.name); + if (parts) { + sectionNames.add(parts.section); + continue; + } + const rowDelete = parseRepeatingRowDeleteTarget(attr.name); + if (rowDelete) { + const section = getSectionFromRepeatingPrefix(rowDelete.sectionPrefix); + if (section) { + sectionNames.add(section); + } + } + } + return Array.from(sectionNames); + } + async function getAllRepOrders(characterID, sectionNames) { + const repOrders = {}; + for (const section of sectionNames) { + const repOrderString = await getRepOrderForSection(characterID, section); + const stored = repOrderString && typeof repOrderString === "string" + ? convertRepOrderToArray(repOrderString) + : []; + const discovered = discoverRowIds(characterID, section); + repOrders[section] = mergeRepOrder(stored, discovered); + } + return repOrders; + } + + function processModifierValue(modification, resolvedAttributes, { shouldEvaluate = false, shouldAlias = false } = {}) { + let finalValue = replacePlaceholders(modification, resolvedAttributes); + if (shouldAlias) { + finalValue = replaceAliasCharacters(finalValue); + } + if (shouldEvaluate) { + finalValue = evaluateExpression(finalValue); + } + return finalValue; + } + function replaceAliasCharacters(modification) { + let result = modification; + for (const alias in ALIAS_CHARACTERS) { + const original = ALIAS_CHARACTERS[alias]; + const regex = new RegExp(`\\${alias}`, "g"); + result = result.replace(regex, original); + } + return result; + } + function replacePlaceholders(value, attributes) { + if (typeof value !== "string") + return value; + return value.replace(/%([a-zA-Z0-9_]+)%/g, (match, name) => { + const replacement = attributes[name]; + return replacement !== undefined ? String(replacement) : match; + }); + } + function evaluateExpression(expression) { + try { + const stringValue = String(expression); + const result = eval(stringValue); + return result; + } + catch { + return expression; + } + } + function processModifierName(name, { repeatingID, repOrder }) { + let result = name; + const hasCreate = result.includes("CREATE"); + if (hasCreate && repeatingID) { + if (/-CREATE/i.test(result)) { + result = result.replace(/-CREATE/i, repeatingID); + } + else { + result = result.replace(/CREATE/i, repeatingID); + } + } + const rowIndexMatch = result.match(/\$(\d+)/); + if (rowIndexMatch && repOrder) { + const rowIndex = parseInt(rowIndexMatch[1], 10); + const rowID = repOrder[rowIndex]; + if (!rowID) + return result; + result = result.replace(`$${rowIndex}`, rowID); + } + return result; + } + function processModifications(modifications, resolved, options, repOrders, errors = [], characterName = "") { + const processedModifications = []; + const repeatingID = libUUID.generateRowID(); + for (const mod of modifications) { + if (!mod.name) + continue; + let processedName = mod.name; + const parts = extractRepeatingParts(mod.name); + if (parts) { + const hasCreate = hasCreateIdentifier(parts.identifier); + const repOrder = repOrders[parts.section] || []; + processedName = processModifierName(mod.name, { + repeatingID: hasCreate ? repeatingID : parts.identifier, + repOrder, + }); + if (hasIndexIdentifier(mod.name)) { + const unresolvedIndex = processedName.match(/\$(\d+)/); + if (unresolvedIndex) { + errors.push(`Repeating row number ${unresolvedIndex[1]} invalid for character ${characterName} and repeating section repeating_${parts.section}.`); + continue; + } + } + } + let processedCurrent = undefined; + if (mod.current !== undefined && mod.current !== "undefined") { + processedCurrent = String(mod.current); + processedCurrent = processModifierValue(processedCurrent, resolved, { + shouldEvaluate: options.evaluate, + shouldAlias: options.replace, + }); + } + let processedMax = undefined; + if (mod.max !== undefined) { + processedMax = String(mod.max); + processedMax = processModifierValue(processedMax, resolved, { + shouldEvaluate: options.evaluate, + shouldAlias: options.replace, + }); + } + const processedMod = { + name: processedName, + }; + if (processedCurrent !== undefined) { + processedMod.current = processedCurrent; + } + if (processedMax !== undefined) { + processedMod.max = processedMax; + } + processedModifications.push(processedMod); + } + return processedModifications; + } + + const permissions = { + playerID: "", + isGM: false, + canModify: false, + }; + function checkPermissions(playerID) { + const player = getObj("player", playerID); + if (!player) { + if ("API" === playerID) { + // allow API full access + setPermissions(playerID, true, true); + return true; + } + log(`Player with ID ${playerID} not found.`); + return false; + } + const isGM = playerIsGM(playerID); + const config = getConfig(); + const playersCanModify = config.playersCanModify || false; + const canModify = isGM || playersCanModify; + setPermissions(playerID, isGM, canModify); + return true; + } + function setPermissions(playerID, isGM, canModify) { + permissions.playerID = playerID; + permissions.isGM = isGM; + permissions.canModify = canModify; + } + function getPermissions() { + return { ...permissions }; + } + function checkPermissionForTarget(playerID, target) { + const isAPI = "API" == playerID; + if (isAPI) { + return true; + } + const player = getObj("player", playerID); + if (!player) { + return false; + } + const isGM = playerIsGM(playerID); + if (isGM) { + return true; + } + if (getConfig().playersCanModify) { + return true; + } + const character = getObj("character", target); + if (!character) { + return false; + } + const controlledBy = (character.get("controlledby") || "").split(","); + return controlledBy.includes(playerID); + } + + function generateSelectedTargets(message, type) { + const errors = []; + const targets = []; + if (!message.selected) + return { targets, errors }; + for (const token of message.selected) { + const tokenObj = getObj("graphic", token._id); + if (!tokenObj) { + errors.push(`Selected token with ID ${token._id} not found.`); + continue; + } + if (tokenObj.get("_subtype") !== "token") { + errors.push(`Selected object with ID ${token._id} is not a token.`); + continue; + } + const represents = tokenObj.get("represents"); + const character = getObj("character", represents); + if (!character) { + errors.push(`Token with ID ${token._id} does not represent a character.`); + continue; + } + const inParty = character.get("inParty"); + if (type === "sel-noparty" && inParty) { + continue; + } + if (type === "sel-party" && !inParty) { + continue; + } + targets.push(character.id); + } + return { + targets, + errors, + }; + } + function generateAllTargets(type) { + const { isGM } = getPermissions(); + const errors = []; + if (!isGM) { + errors.push(`Only GMs can use the '${type}' target option.`); + return { + targets: [], + errors, + }; + } + const characters = findObjs({ _type: "character" }); + if (type === "all") { + return { + targets: characters.map(char => char.id), + errors, + }; + } + else if (type === "allgm") { + const targets = characters.filter(char => { + const controlledBy = char.get("controlledby"); + return !controlledBy; + }).map(char => char.id); + return { + targets, + errors, + }; + } + else if (type === "allplayers") { + const targets = characters.filter(char => { + const controlledBy = char.get("controlledby"); + return !!controlledBy; + }).map(char => char.id); + return { + targets, + errors, + }; + } + return { + targets: [], + errors: [`Unknown target type '${type}'.`], + }; + } + function generateCharacterIDTargets(values) { + const { playerID } = getPermissions(); + const targets = []; + const errors = []; + for (const charID of values) { + const character = getObj("character", charID); + if (!character) { + errors.push(`Character with ID ${charID} not found.`); + continue; + } + const characterID = character.id; + const hasPermission = checkPermissionForTarget(playerID, characterID); + if (!hasPermission) { + errors.push(`Permission error. You do not have permission to modify character with ID ${charID}.`); + continue; + } + targets.push(characterID); + } + return { + targets, + errors, + }; + } + function generatePartyTargets() { + const { isGM } = getPermissions(); + const { playersCanTargetParty } = getConfig(); + const targets = []; + const errors = []; + if (!isGM && !playersCanTargetParty) { + errors.push("Only GMs can use the 'party' target option."); + return { + targets, + errors, + }; + } + const characters = findObjs({ _type: "character", inParty: true }); + for (const character of characters) { + const characterID = character.id; + targets.push(characterID); + } + return { + targets, + errors, + }; + } + function splitCommaSeparatedValues(valueString) { + if (!valueString) { + return []; + } + return valueString.split(/\s*,\s*/).map(v => v.trim()).filter(v => v.length > 0); + } + function parseTargetOption(option) { + const trimmed = option.trim(); + const spaceIndex = trimmed.indexOf(" "); + if (spaceIndex === -1) { + return { type: trimmed, values: [] }; + } + const type = trimmed.slice(0, spaceIndex); + const remainder = trimmed.slice(spaceIndex + 1).trim(); + if (type === "name" || type === "charid") { + return { type, values: splitCommaSeparatedValues(remainder) }; + } + return { type, values: [] }; + } + function generateNameTargets(values) { + const { playerID } = getPermissions(); + const targets = []; + const errors = []; + for (const name of values) { + const characters = findObjs({ _type: "character", name }, { caseInsensitive: true }); + if (characters.length === 0) { + errors.push(`Character with name "${name}" not found.`); + continue; + } + if (characters.length > 1) { + errors.push(`Multiple characters found with name "${name}". Please use character ID instead.`); + continue; + } + const character = characters[0]; + const characterID = character.id; + const hasPermission = checkPermissionForTarget(playerID, characterID); + if (!hasPermission) { + errors.push(`Permission error. You do not have permission to modify character with name "${name}".`); + continue; + } + targets.push(characterID); + } + return { + targets, + errors, + }; + } + function generateTargets(message, targetOptions) { + const characterIDs = []; + const errors = []; + for (const option of targetOptions) { + const { type, values } = parseTargetOption(option); + if (type === "sel" || type === "sel-noparty" || type === "sel-party") { + const results = generateSelectedTargets(message, type); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "all" || type === "allgm" || type === "allplayers") { + const results = generateAllTargets(type); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "charid") { + const results = generateCharacterIDTargets(values); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "name") { + const results = generateNameTargets(values); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "party") { + const results = generatePartyTargets(); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + } + const targets = Array.from(new Set(characterIDs)); + return { + targets, + errors, + }; + } + + const timerMap = new Map(); + function startTimer(key, duration = 50, callback) { + // Clear any existing timer for the same key + const existingTimer = timerMap.get(key); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + callback(); + timerMap.delete(key); + }, duration); + timerMap.set(key, timer); + } + function clearTimer(key) { + const timer = timerMap.get(key); + if (timer) { + clearTimeout(timer); + timerMap.delete(key); + } + } + + function broadcastHeader() { + log(`${scriptJson.name} v${scriptJson.version} by ${scriptJson.authors.join(", ")} loaded.`); + } + function checkDependencies() { + const errors = []; + if (libSmartAttributes === undefined) { + errors.push("libSmartAttributes is required but not found. Please ensure the libSmartAttributes script is installed."); + } + if (libUUID === undefined) { + errors.push("libUUID is required but not found. Please ensure the libUUID script is installed."); + } + if (errors.length > 0) { + sendErrors("gm", "Missing Dependencies", errors); + } + return errors.length === 0; + } + async function acceptMessage(msg) { + // State + const errors = []; + const messages = []; + const result = {}; + // Parse Message + const parsed = parseMessage(msg.content); + if (!parsed) { + return errorOut("Could not parse command. Check that command options use -- (double dash).", msg.playerid, errors, normalizeCommandOutputOptions()); + } + const { operation, targeting, options, changes, references, feedback, } = parsed; + const output = normalizeCommandOutputOptions(options); + // Start Timer + startTimer("chatsetattr", 8000, () => sendDelayMessage(msg.playerid, output)); + // Check Config and Permissions + const config = getConfig(); + const isAPI = "API" === msg.playerid; + const isGM = playerIsGM(msg.playerid); + if (options.evaluate && !isAPI && !isGM && !config.playersCanEvaluate) { + return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors, output); + } + if (targeting.includes("party") && !isAPI && !isGM && !config.playersCanTargetParty) { + return errorOut("You do not have permission to target the party.", msg.playerid, errors, output); + } + if ((operation === "modattr" || operation === "modbattr") && !isAPI && !isGM && !config.playersCanModify) { + return errorOut("You do not have permission to modify attributes.", msg.playerid, errors, output); + } + // Preprocess + const { targets, errors: targetErrors } = generateTargets(msg, targeting); + errors.push(...targetErrors); + if (targets.length === 0) { + return errorOut("No valid targets found.", msg.playerid, errors, output); + } + const request = generateRequest(references, changes); + const command = handlers[operation]; + if (!command) { + return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors, output); + } + // Execute + const priorValues = {}; + const pendingChanges = {}; + for (const target of targets) { + const attrs = await getAttributes(target, request); + priorValues[target] = attrs; + const sectionNames = getAllSectionNames(changes); + const repOrders = await getAllRepOrders(target, sectionNames); + let effectiveChanges = changes; + if (operation === "delattr") { + effectiveChanges = expandRepeatingRowDeletes(target, changes, repOrders, errors, getCharName(target)); + } + const modifications = processModifications(effectiveChanges, attrs, options, repOrders, errors, getCharName(target)); + const response = await command(modifications, target, references, options.nocreate, feedback); + if (response.errors.length > 0) { + errors.push(...response.errors); + continue; + } + pendingChanges[target] = modifications; + result[target] = response.result; + } + const updateResult = await makeUpdate(operation, result, { + noCreate: options.nocreate, + priorValues}); + clearTimer("chatsetattr"); + errors.push(...updateResult.errors); + for (const target in result) { + const filteredResult = filterSuccessfulResult(target, result[target], updateResult.failed); + if (Object.keys(filteredResult).length === 0) { + continue; + } + const characterName = getCharName(target); + const targetChanges = pendingChanges[target] ?? []; + let message; + if (feedback?.content) { + message = createFeedbackMessage(characterName, feedback, priorValues[target] ?? {}, filteredResult); + } + else if (operation === "delattr") { + message = formatDeleteFeedback(characterName, targetChanges, filteredResult); + } + else { + message = formatSettingFeedback(characterName, targetChanges, filteredResult); + } + if (message) { + messages.push(message); + } + } + sendErrors(msg.playerid, "Errors", errors, feedback?.from, output); + const delSetTitle = operation === "delattr" ? "Deleting attributes" : "Setting attributes"; + const feedbackTitle = feedback?.header ?? delSetTitle; + if (messages.length > 0) { + sendMessages(msg.playerid, feedbackTitle, messages, { + from: feedback?.from, + public: feedback?.public, + }, output); + } + } + function errorOut(errorText, playerid, errors, output) { + errors.push(errorText); + sendErrors(playerid, "Errors", errors, undefined, output); + clearTimer("chatsetattr"); + } + function filterSuccessfulResult(target, targetResult, failed) { + const filtered = {}; + for (const key in targetResult) { + if (!failed.includes(`${target}:${key}`)) { + filtered[key] = targetResult[key]; + } + } + return filtered; + } + function generateRequest(references, changes) { + const referenceSet = new Set(references); + for (const change of changes) { + if (!change.name) { + continue; + } + if (!referenceSet.has(change.name)) { + referenceSet.add(change.name); + } + const maxName = `${change.name}_max`; + if (!referenceSet.has(maxName)) { + referenceSet.add(maxName); + } + } + return Array.from(referenceSet); + } + function registerHandlers() { + broadcastHeader(); + if (!checkDependencies()) { + return; + } + if (!isBeaconSupported()) { + sendBeaconUnsupportedNotice(); + } + on("chat:message", (msg) => { + if (msg.type !== "api") { + const inlineMessage = extractMessageFromRollTemplate(msg); + if (!inlineMessage) + return; + msg.content = inlineMessage; + } + msg.content = normalizeMultilineCommand(msg.content); + msg.content = normalizeTemplateRollProperties(msg.content); + msg.content = processInlinerolls(msg); + const debugReset = msg.content.startsWith("!setattrs-debugreset"); + if (debugReset) { + log("ChatSetAttr: Debug - resetting state."); + state.ChatSetAttr = {}; + return; + } + const debugVersion = msg.content.startsWith("!setattrs-debugversion"); + if (debugVersion) { + log("ChatSetAttr: Debug - setting state schema version to 3."); + if (!state.ChatSetAttr) + state.ChatSetAttr = {}; + state.ChatSetAttr.version = 3; + return; + } + const isHelpMessage = checkHelpMessage(msg.content); + if (isHelpMessage) { + handleHelpCommand(); + return; + } + const isConfigMessage = checkConfigMessage(msg.content); + if (isConfigMessage) { + if (!playerIsGM(msg.playerid)) { + return; + } + handleConfigCommand(msg.content, msg.playerid); + return; + } + const validMessage = validateMessage(msg.content); + if (!validMessage) + return; + if (checkPermissions(msg.playerid)) { + acceptMessage(msg); + } + }); + } + + const LI_STYLE = s({ + marginBottom: "4px", + }); + const WRAPPER_STYLE = s(frameStyleBase); + const PARAGRAPH_SPACING_STYLE = s({ + marginTop: "8px", + marginBottom: "8px", + }); + function createVersionMessage() { + return (h("div", { style: WRAPPER_STYLE }, + h("p", null, + h("strong", null, "ChatSetAttr has been updated to version 2.0!")), + h("p", null, "This update includes important changes to improve compatibility and performance."), + h("strong", null, "Changelog:"), + h("ul", null, + h("li", { style: LI_STYLE }, "Added compatibility for Beacon sheets, including the new Dungeons and Dragons character sheet."), + h("li", { style: LI_STYLE }, + "Added support for targeting party members with the ", + h("code", null, "--party"), + " flag."), + h("li", { style: LI_STYLE }, + "Added support for excluding party members when targeting selected tokens with the ", + h("code", null, "--sel-noparty"), + " flag."), + h("li", { style: LI_STYLE }, + "Added support for including only party members when targeting selected tokens with the ", + h("code", null, "--sel-party"), + " flag.")), + h("p", null, "Please review the updated documentation for details on these new features and how to use them."), + h("div", { style: PARAGRAPH_SPACING_STYLE }, + h("strong", null, + "If you encounter any bugs or issues, please report them via the ", + h("a", { href: "https://help.roll20.net/hc/en-us/requests/new" }, "Roll20 Helpdesk"))), + h("div", { style: PARAGRAPH_SPACING_STYLE }, + h("strong", null, + "If you want to create a handout with the updated documentation, use the command ", + h("code", null, "!setattr-help"), + " or click the button below"), + h("a", { href: "!setattr-help" }, "Create Help Handout")))).html; + } + + const v2_0 = { + appliesTo: "<=3", + version: 4, + update: () => { + setConfig({ + version: 4, + playersCanTargetParty: true, + scriptVersion: scriptJson.version, + }); + const title = "ChatSetAttr Updated to Version 2.0"; + const content = createVersionMessage(); + sendNotification(title, content, false); + }, + }; + + const VERSION_HISTORY = [ + v2_0, + ]; + function welcome() { + const hasWelcomed = hasFlag("welcome"); + if (hasWelcomed) { + return; + } + sendWelcomeMessage(); + setFlag("welcome"); + } + function update() { + log("ChatSetAttr: Checking for state schema updates..."); + const currentSchemaVersion = getPersistedSchemaVersion(); + log(`ChatSetAttr: Current state schema version: ${currentSchemaVersion}`); + checkForUpdates(currentSchemaVersion); + persistStateVersionMetadata(); + } + function checkForUpdates(currentSchemaVersion) { + for (const migration of VERSION_HISTORY) { + log(`ChatSetAttr: Evaluating schema migration to ${migration.version} (appliesTo: ${migration.appliesTo})`); + const applies = migration.appliesTo; + const threshold = Number(applies.replace(/(<=|<|>=|>|=)/, "").trim()); + const comparison = applies.replace(String(threshold), "").trim(); + const compared = compareSchemaVersions(currentSchemaVersion, threshold); + let shouldApply = false; + switch (comparison) { + case "<=": + shouldApply = compared <= 0; + break; + case "<": + shouldApply = compared < 0; + break; + case ">=": + shouldApply = compared >= 0; + break; + case ">": + shouldApply = compared > 0; + break; + case "=": + shouldApply = compared === 0; + break; + } + if (shouldApply) { + migration.update(); + currentSchemaVersion = migration.version; + updateVersionInState(currentSchemaVersion); + } + } + } + function compareSchemaVersions(current, threshold) { + return current - threshold; + } + function updateVersionInState(newSchemaVersion) { + setConfig({ version: newSchemaVersion }); + } + + on("ready", () => { + checkGlobalConfig(); + registerHandlers(); + syncHelpHandoutOnStartup(); + syncScriptVersion(); + update(); + welcome(); + persistStateVersionMetadata(); + }); + + exports.registerObserver = registerObserver; + + return exports; + +})({}); diff --git a/ChatSetAttr/MAINTAINER.md b/ChatSetAttr/MAINTAINER.md new file mode 100644 index 0000000000..b8eda552ec --- /dev/null +++ b/ChatSetAttr/MAINTAINER.md @@ -0,0 +1,196 @@ +# ChatSetAttr — Maintainer Guide + +This document is for developers working on the ChatSetAttr Roll20 API script. User-facing documentation lives in the generated [`README.md`](README.md) and in-game via `!setattr-help`. + +## Prerequisites + +- Node.js 18 or newer +- npm (used below; `pnpm` and `yarn` work equivalently if you prefer) + +Install dependencies from the project root: + +```bash +npm install +``` + +ChatSetAttr depends on two sibling packages that must be present in the Roll20 game at runtime: + +- **libSmartAttributes** +- **libUUID** + +These are linked as dev dependencies for local development and testing. + +## Project layout + +| Path | Purpose | +|------|---------| +| `src/` | TypeScript source (modules, templates, tests) | +| `docs/help/content.json` | Single source of truth for user documentation | +| `scripts/generate-docs.ts` | Regenerates README, script description, and content revision | +| `script.json` | Roll20 One-Click metadata (version, description, options) | +| `rollup.config.ts` | Bundles `src/index.ts` into deployable `.js` files | + +Source is written in TypeScript with JSX-style templates (`h()` helper) for HTML chat and handout output. Rollup produces an IIFE bundle named `ChatSetAttr.js`, plus a copy under `/ChatSetAttr.js` (for example `2.0/ChatSetAttr.js`). + +## Typical workflow + +A normal change goes through these phases in order. + +### 1. Edit source + +Implement changes under `src/`. Add or update unit tests under `src/__tests__/`. + +### 2. Lint + +```bash +npm run lint +``` + +Auto-fix where possible: + +```bash +npm run lint:fix +``` + +### 3. Test + +Run the full suite once (CI-style): + +```bash +npm run test:run +``` + +During development, use watch mode: + +```bash +npm test +# or +npm run test:watch +``` + +### 4. Coverage (optional but recommended before larger changes) + +```bash +npm run test:coverage +``` + +This prints a summary table and writes an HTML report to `coverage/index.html`. + +### 5. Documentation + +User docs are **not** edited in README or `script.json` directly. + +1. Edit [`docs/help/content.json`](docs/help/content.json) only. +2. See [`docs/help/README.md`](docs/help/README.md) for block types and inline markup rules. +3. Regenerate derived files: + + ```bash + npm run docs:generate + ``` + + This updates: + + - `README.md` + - `script.json` → `description` + - `docs/help/content.revision.json` (content hash + `updatedAt` timestamp; bumps only when `content.json` changes) + +4. Verify nothing is stale: + + ```bash + npm run docs:check + ``` + +At runtime, if a player has already created the help handout (`!setattr-help`), the script auto-refreshes it on API startup when the bundled revision is newer than `state.ChatSetAttr.helpContentUpdatedAt`. + +### 6. Build + +Produce the Roll20-ready bundle: + +```bash +npm run build +``` + +Output: + +- `ChatSetAttr.js` — latest build at repo root +- `/ChatSetAttr.js` — versioned copy (version comes from `script.json`) + +For continuous rebuilds while editing: + +```bash +npm start +``` + +### Pre-release checklist + +Before publishing a new build to Roll20: + +1. `npm run lint` +2. `npm run test:run` +3. `npm run docs:check` (run `npm run docs:generate` first if docs changed) +4. `npm run build` +5. Confirm `script.json` `version` matches the intended release +6. Upload the appropriate `ChatSetAttr.js` (or update the One-Click script source) + +## State schema vs script version + +Two separate version concepts exist: + +| Field | Meaning | Example | +|-------|---------|---------| +| `state.ChatSetAttr.version` | **State schema** layout revision (integer) | `3` in 1.x, `4` in 2.0 | +| `script.json` `version` | **Script release** shown in logs and One-Click | `"2.0"` | +| `state.ChatSetAttr.scriptVersion` | Last seen script release (synced on startup) | `"2.0"` | + +1.x wrote `state.ChatSetAttr.version = 3` when the state layout changed. 2.0 uses schema `4` for its expanded config (`playersCanTargetParty`, `flags`, `helpContentUpdatedAt`, etc.). Do not store the script release string in `version`. + +`getStateSchemaVersion()` in [`src/modules/config.ts`](src/modules/config.ts) reads the persisted numeric schema from `state.ChatSetAttr.version`. Missing or invalid values map to `0`. Migrations run when the persisted schema is `0` or `<=3`. + +## Adding a new state schema or script release + +State migrations run on API `ready` via [`src/modules/versioning.ts`](src/modules/versioning.ts). Each `VersionObject` in `VERSION_HISTORY` has: + +- `appliesTo` — comparison against the **state schema** (e.g. `"<=3"`, `"<5"`) +- `version` — target schema number written to `state.ChatSetAttr.version` after migration +- `update` — one-time upgrade work (state changes, GM notification, etc.) + +To add a new schema revision (for example schema `5` with script release `2.1`): + +1. **Bump `script.json`** — set `"version"` to the new release string. + +2. **Bump `STATE_SCHEMA_VERSION`** in [`src/modules/config.ts`](src/modules/config.ts) and extend `DEFAULT_CONFIG` if new fields are added. + +3. **Create a migration module** — add `src/versions/2.1.0.ts` exporting a `VersionObject` with `appliesTo: "<=4"` and `version: 5`. Follow [`src/versions/2.0.0.ts`](src/versions/2.0.0.ts). + +4. **Create an update message template** (optional) — add `src/templates/versions/2.1.0.tsx` for in-game changelog HTML. + +5. **Register the migration** — append to `VERSION_HISTORY` in [`src/modules/versioning.ts`](src/modules/versioning.ts). Order matters. + +6. **Add tests** — cover `getStateSchemaVersion`, migration gating, and new config fields. + +7. **Update user docs** — edit `docs/help/content.json`, then `npm run docs:generate`. + +8. **Build and verify** — `npm run test:run && npm run build`. + +`syncScriptVersion()` runs on every `ready` and updates `scriptVersion` from `script.json` without triggering migrations. + +Use `!setattrs-debugversion` in-game to reset `state.ChatSetAttr.version` to `3` for migration testing. + +## Configuration and state + +Runtime settings persist in `state.ChatSetAttr`. Global One-Click checkboxes sync through `checkGlobalConfig()` on startup. See `src/modules/config.ts` for the full schema. + +Flags such as `welcome` (first-run message) and `helpContentUpdatedAt` (last applied help revision) also live in state and should be considered when writing migrations or tests. + +## Package manager notes + +All scripts above use `npm run ', + 'Body with tags', + ); + + expect(message).toContain("<script>alert("x")</script>"); + expect(message).toContain("<em>tags</em>"); + }); + + it("should include wrapper, header, and body structure", () => { + const message = createNoticeMessage("Header", "Content"); + + expect(message).toMatch(/]*>.*]*>Header<\/div>.*]*>Content<\/div>.*<\/div>/s); + }); +}); diff --git a/ChatSetAttr/src/__tests__/templates/notification.test.ts b/ChatSetAttr/src/__tests__/templates/notification.test.ts new file mode 100644 index 0000000000..27162f3ee0 --- /dev/null +++ b/ChatSetAttr/src/__tests__/templates/notification.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; + +import { createNotifyMessage } from "../../templates/notification"; +import { createVersionMessage } from "../../templates/versions/2.0.0"; +import { createWelcomeMessage } from "../../templates/welcome"; + +describe("notification", () => { + it("should render welcome message HTML without escaping tags", () => { + const message = createNotifyMessage("Welcome to ChatSetAttr!", createWelcomeMessage()); + + expect(message).toContain("

Thank you for installing ChatSetAttr.

"); + expect(message).not.toContain("<p>"); + }); + + it("should render version update HTML without escaping tags", () => { + const message = createNotifyMessage( + "ChatSetAttr Updated to Version 2.0", + createVersionMessage(), + ); + + expect(message).toContain("ChatSetAttr has been updated to version 2.0!"); + expect(message).not.toContain("<strong>"); + expect(message).toContain("
    "); + expect(message).not.toContain("<ul>"); + }); + + it("should still escape the notification title", () => { + const message = createNotifyMessage( + 'Title with ', + "

    Body

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

    Body

    "); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/attributes.test.ts b/ChatSetAttr/src/__tests__/unit/attributes.test.ts new file mode 100644 index 0000000000..71d26a9103 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/attributes.test.ts @@ -0,0 +1,475 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + getAttributes, + setAttributes, + setSingleAttribute, + deleteAttributes, + deleteSingleAttribute +} from "../../modules/attributes"; +import type { Attribute, AttributeRecord } from "../../types"; + +// Mock libSmartAttributes +const mockGetAttribute = vi.fn(); +const mockSetAttribute = vi.fn(); +const mockDeleteAttribute = vi.fn(); + +const mocklibSmartAttributes = { + getAttribute: mockGetAttribute, + setAttribute: mockSetAttribute, + deleteAttribute: mockDeleteAttribute +}; + +// Setup global libSmartAttributes mock +global.libSmartAttributes = mocklibSmartAttributes; + +/** Matches vitest.setup state (useWorkers: true) and buildSetAttributeOptions defaults */ +const defaultSetOptions = { noCreate: false, setWithWorker: true }; + +describe("attributes module", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getAttributes", () => { + const target = "character-123"; + + it("should get single attribute from array", async () => { + mockGetAttribute.mockResolvedValue("Test Value"); + + const result = await getAttributes(target, ["strength"]); + + expect(result).toEqual({ strength: "Test Value" }); + expect(mockGetAttribute).toHaveBeenCalledWith(target, "strength", "current"); + }); + + it("should get multiple attributes from array", async () => { + mockGetAttribute + .mockResolvedValueOnce("18") + .mockResolvedValueOnce("16") + .mockResolvedValueOnce("14"); + + const result = await getAttributes(target, ["strength", "dexterity", "constitution"]); + + expect(result).toEqual({ + strength: "18", + dexterity: "16", + constitution: "14" + }); + expect(mockGetAttribute).toHaveBeenCalledTimes(3); + expect(mockGetAttribute).toHaveBeenNthCalledWith(1, target, "strength", "current"); + expect(mockGetAttribute).toHaveBeenNthCalledWith(2, target, "dexterity", "current"); + expect(mockGetAttribute).toHaveBeenNthCalledWith(3, target, "constitution", "current"); + }); + + it("should handle _max suffix for max attributes", async () => { + mockGetAttribute.mockResolvedValue("100"); + + const result = await getAttributes(target, ["hp_max"]); + + expect(result).toEqual({ hp_max: "100" }); + expect(mockGetAttribute).toHaveBeenCalledWith(target, "hp", "max"); + }); + + it("should get attributes from object record", async () => { + mockGetAttribute + .mockResolvedValueOnce("Test") + .mockResolvedValueOnce("Value"); + + const record: AttributeRecord = { name: undefined, description: undefined }; + const result = await getAttributes(target, record); + + expect(result).toEqual({ + name: "Test", + description: "Value" + }); + expect(mockGetAttribute).toHaveBeenCalledTimes(2); + }); + + it("should clean attribute names by removing special characters", async () => { + mockGetAttribute.mockResolvedValue("cleaned"); + + const result = await getAttributes(target, ["test-attr!", "another@attr#"]); + + expect(result).toEqual({ + testattr: "cleaned", + anotherattr: "cleaned" + }); + expect(mockGetAttribute).toHaveBeenCalledWith(target, "testattr", "current"); + expect(mockGetAttribute).toHaveBeenCalledWith(target, "anotherattr", "current"); + }); + + it("should handle libSmartAttributes errors by returning undefined", async () => { + mockGetAttribute.mockRejectedValue(new Error("Attribute not found")); + + const result = await getAttributes(target, ["nonexistent"]); + + expect(result).toEqual({ nonexistent: undefined }); + }); + + it("should handle mixed success and failure", async () => { + mockGetAttribute + .mockResolvedValueOnce("success") + .mockRejectedValueOnce(new Error("failed")); + + const result = await getAttributes(target, ["existing", "missing"]); + + expect(result).toEqual({ + existing: "success", + missing: undefined + }); + }); + + it("should handle empty array", async () => { + const result = await getAttributes(target, []); + + expect(result).toEqual({}); + expect(mockGetAttribute).not.toHaveBeenCalled(); + }); + + it("should handle empty object", async () => { + const result = await getAttributes(target, {}); + + expect(result).toEqual({}); + expect(mockGetAttribute).not.toHaveBeenCalled(); + }); + }); + + describe("setSingleAttribute", () => { + const target = "character-123"; + const options = { replace: true }; + + it("should set current attribute", async () => { + mockSetAttribute.mockResolvedValue(true); + + await setSingleAttribute(target, "strength", 18, options); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "strength", 18, "current", defaultSetOptions); + }); + + it("should set max attribute when isMax is true", async () => { + mockSetAttribute.mockResolvedValue(true); + + await setSingleAttribute(target, "hp", 100, options, true); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "hp", 100, "max", defaultSetOptions); + }); + + it("should handle string values", async () => { + mockSetAttribute.mockResolvedValue(true); + + await setSingleAttribute(target, "name", "Test Character", options); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "name", "Test Character", "current", defaultSetOptions); + }); + + it("should handle boolean values", async () => { + mockSetAttribute.mockResolvedValue(true); + + await setSingleAttribute(target, "isDead", false, options); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "isDead", false, "current", defaultSetOptions); + }); + + it("should handle numeric values", async () => { + mockSetAttribute.mockResolvedValue(true); + + await setSingleAttribute(target, "level", 5, options); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "level", 5, "current", defaultSetOptions); + }); + + it("should throw when setAttribute returns false", async () => { + mockSetAttribute.mockResolvedValue(false); + + await expect(setSingleAttribute(target, "strength", 18, options)) + .rejects.toThrow("Failed to set attribute 'strength' on target 'character-123'."); + }); + }); + + describe("setAttributes", () => { + const target = "character-123"; + const options = { replace: true, silent: false }; + + it("should set single attribute with current value", async () => { + mockSetAttribute.mockResolvedValue(true); + + const attributes: Attribute[] = [ + { name: "strength", current: 18 } + ]; + + await setAttributes(target, attributes, options); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "strength", 18, "current", defaultSetOptions); + }); + + it("should set single attribute with max value", async () => { + mockSetAttribute.mockResolvedValue(true); + + const attributes: Attribute[] = [ + { name: "hp", max: 100 } + ]; + + await setAttributes(target, attributes, options); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "hp", 100, "max", defaultSetOptions); + }); + + it("should set both current and max values", async () => { + mockSetAttribute.mockResolvedValue(true); + + const attributes: Attribute[] = [ + { name: "hp", current: 75, max: 100 } + ]; + + await setAttributes(target, attributes, options); + + expect(mockSetAttribute).toHaveBeenCalledTimes(2); + expect(mockSetAttribute).toHaveBeenNthCalledWith(1, target, "hp", 75, "current", defaultSetOptions); + expect(mockSetAttribute).toHaveBeenNthCalledWith(2, target, "hp", 100, "max", defaultSetOptions); + }); + + it("should set multiple attributes", async () => { + mockSetAttribute.mockResolvedValue(true); + + const attributes: Attribute[] = [ + { name: "strength", current: 18 }, + { name: "dexterity", current: 16 }, + { name: "hp", current: 75, max: 100 } + ]; + + await setAttributes(target, attributes, options); + + expect(mockSetAttribute).toHaveBeenCalledTimes(4); + expect(mockSetAttribute).toHaveBeenNthCalledWith(1, target, "strength", 18, "current", defaultSetOptions); + expect(mockSetAttribute).toHaveBeenNthCalledWith(2, target, "dexterity", 16, "current", defaultSetOptions); + expect(mockSetAttribute).toHaveBeenNthCalledWith(3, target, "hp", 75, "current", defaultSetOptions); + expect(mockSetAttribute).toHaveBeenNthCalledWith(4, target, "hp", 100, "max", defaultSetOptions); + }); + + it("should handle different value types", async () => { + mockSetAttribute.mockResolvedValue(true); + + const attributes: Attribute[] = [ + { name: "name", current: "Test Character" }, + { name: "level", current: 5 }, + { name: "isDead", current: false } + ]; + + await setAttributes(target, attributes, options); + + expect(mockSetAttribute).toHaveBeenCalledTimes(3); + expect(mockSetAttribute).toHaveBeenNthCalledWith(1, target, "name", "Test Character", "current", defaultSetOptions); + expect(mockSetAttribute).toHaveBeenNthCalledWith(2, target, "level", 5, "current", defaultSetOptions); + expect(mockSetAttribute).toHaveBeenNthCalledWith(3, target, "isDead", false, "current", defaultSetOptions); + }); + + it("should throw error if attribute has no name", async () => { + const attributes: Attribute[] = [ + { current: 18 } // Missing name + ]; + + await expect(setAttributes(target, attributes, options)) + .rejects.toThrow("Attribute must have a name defined."); + }); + + it("should throw error if attribute has neither current nor max value", async () => { + const attributes: Attribute[] = [ + { name: "strength" } // Missing both current and max + ]; + + await expect(setAttributes(target, attributes, options)) + .rejects.toThrow("Attribute must have at least a current or max value defined."); + }); + + it("should handle empty attributes array", async () => { + await setAttributes(target, [], options); + + expect(mockSetAttribute).not.toHaveBeenCalled(); + }); + + it("should handle Promise.all rejections properly", async () => { + mockSetAttribute.mockRejectedValue(new Error("Set failed")); + + const attributes: Attribute[] = [ + { name: "strength", current: 18 } + ]; + + await expect(setAttributes(target, attributes, options)) + .rejects.toThrow("Set failed"); + }); + + it("should execute all operations in parallel", async () => { + const callOrder: number[] = []; + let callCount = 0; + + mockSetAttribute.mockImplementation(async () => { + const currentCall = ++callCount; + callOrder.push(currentCall); + // Simulate async delay + await new Promise(resolve => setTimeout(resolve, Math.random() * 10)); + return true; + }); + + const attributes: Attribute[] = [ + { name: "attr1", current: 1 }, + { name: "attr2", current: 2 }, + { name: "attr3", current: 3 } + ]; + + await setAttributes(target, attributes, options); + + expect(mockSetAttribute).toHaveBeenCalledTimes(3); + // All calls should have been initiated quickly (in parallel) + expect(callOrder).toEqual([1, 2, 3]); + }); + }); + + describe("deleteSingleAttribute", () => { + const target = "character-123"; + + it("should delete single attribute", async () => { + mockDeleteAttribute.mockResolvedValue(true); + + await deleteSingleAttribute(target, "oldAttribute"); + + expect(mockDeleteAttribute).toHaveBeenCalledWith(target, "oldAttribute"); + }); + + it("should throw when deleteAttribute returns false", async () => { + mockDeleteAttribute.mockResolvedValue(false); + + await expect(deleteSingleAttribute(target, "nonexistent")) + .rejects.toThrow("Failed to delete attribute 'nonexistent' on target 'character-123'."); + }); + }); + + describe("deleteAttributes", () => { + const target = "character-123"; + + it("should delete single attribute", async () => { + mockDeleteAttribute.mockResolvedValue(true); + + await deleteAttributes(target, ["oldAttribute"]); + + expect(mockDeleteAttribute).toHaveBeenCalledWith(target, "oldAttribute"); + }); + + it("should delete multiple attributes", async () => { + mockDeleteAttribute.mockResolvedValue(true); + + const attributeNames = ["attr1", "attr2", "attr3"]; + await deleteAttributes(target, attributeNames); + + expect(mockDeleteAttribute).toHaveBeenCalledTimes(3); + expect(mockDeleteAttribute).toHaveBeenNthCalledWith(1, target, "attr1"); + expect(mockDeleteAttribute).toHaveBeenNthCalledWith(2, target, "attr2"); + expect(mockDeleteAttribute).toHaveBeenNthCalledWith(3, target, "attr3"); + }); + + it("should handle empty array", async () => { + await deleteAttributes(target, []); + + expect(mockDeleteAttribute).not.toHaveBeenCalled(); + }); + + it("should handle mixed success and failure", async () => { + mockDeleteAttribute + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error("Delete failed")) + .mockResolvedValueOnce(true); + + await expect(deleteAttributes(target, ["attr1", "attr2", "attr3"])) + .rejects.toThrow("Delete failed"); + + expect(mockDeleteAttribute).toHaveBeenCalledTimes(3); + }); + + it("should execute all deletions in parallel", async () => { + const callOrder: number[] = []; + let callCount = 0; + + mockDeleteAttribute.mockImplementation(async () => { + const currentCall = ++callCount; + callOrder.push(currentCall); + // Simulate async delay + await new Promise(resolve => setTimeout(resolve, Math.random() * 10)); + return true; + }); + + const attributeNames = ["attr1", "attr2", "attr3"]; + await deleteAttributes(target, attributeNames); + + expect(mockDeleteAttribute).toHaveBeenCalledTimes(3); + // All calls should have been initiated quickly (in parallel) + expect(callOrder).toEqual([1, 2, 3]); + }); + + it("should throw when deleteAttribute returns false", async () => { + mockDeleteAttribute.mockResolvedValue(false); + + await expect(deleteAttributes(target, ["attr1", "attr2", "attr3"])) + .rejects.toThrow("Failed to delete attribute 'attr1' on target 'character-123'."); + + expect(mockDeleteAttribute).toHaveBeenCalledTimes(3); + }); + }); + + describe("integration tests", () => { + const target = "character-123"; + + it("should handle a complete workflow", async () => { + // Setup mocks + mockGetAttribute.mockResolvedValue(undefined); // Attribute doesn't exist + mockSetAttribute.mockResolvedValue(true); + mockDeleteAttribute.mockResolvedValue(true); + + // Get attribute (should be undefined initially) + const initialValue = await getAttributes(target, ["strength"]); + expect(initialValue).toEqual({ strength: undefined }); + + // Set attribute + await setAttributes(target, [{ name: "strength", current: 18 }], {}); + expect(mockSetAttribute).toHaveBeenCalledWith(target, "strength", 18, "current", defaultSetOptions); + + // Mock that attribute now exists + mockGetAttribute.mockResolvedValue(18); + const updatedValue = await getAttributes(target, ["strength"]); + expect(updatedValue).toEqual({ strength: 18 }); + + // Delete attribute + await deleteAttributes(target, ["strength"]); + expect(mockDeleteAttribute).toHaveBeenCalledWith(target, "strength"); + }); + + it("should handle batch operations efficiently", async () => { + mockSetAttribute.mockResolvedValue(true); + mockDeleteAttribute.mockResolvedValue(true); + + const attributes: Attribute[] = [ + { name: "str", current: 18, max: 20 }, + { name: "dex", current: 16, max: 18 }, + { name: "con", current: 14, max: 16 } + ]; + + // Set all attributes + await setAttributes(target, attributes, {}); + expect(mockSetAttribute).toHaveBeenCalledTimes(6); // 3 current + 3 max + + // Delete all attributes + await deleteAttributes(target, ["str", "dex", "con"]); + expect(mockDeleteAttribute).toHaveBeenCalledTimes(3); + }); + + it("should handle error scenarios gracefully", async () => { + // Test that errors in individual operations are properly propagated + mockSetAttribute.mockRejectedValue(new Error("Permission denied")); + + const attributes: Attribute[] = [ + { name: "strength", current: 18 } + ]; + + await expect(setAttributes(target, attributes, {})) + .rejects.toThrow("Permission denied"); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/unit/beaconSupport.test.ts b/ChatSetAttr/src/__tests__/unit/beaconSupport.test.ts new file mode 100644 index 0000000000..83db0281c7 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/beaconSupport.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mockCampaign } from "../../__mocks__/apiObjects.mock"; +import { isBeaconSupported } from "../../modules/beaconSupport"; + +describe("isBeaconSupported", () => { + const originalCampaign = global.Campaign; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + global.Campaign = originalCampaign; + }); + + it("should return false when computedSummary is missing", () => { + global.Campaign = mockCampaign({}); + + expect(isBeaconSupported()).toBe(false); + }); + + it("should return false when computedSummary is undefined", () => { + global.Campaign = mockCampaign({ computedSummary: undefined }); + + expect(isBeaconSupported()).toBe(false); + }); + + it("should return true when computedSummary is present", () => { + global.Campaign = mockCampaign({ computedSummary: {} }); + + expect(isBeaconSupported()).toBe(true); + }); + + it("should return false when Campaign throws", () => { + global.Campaign = vi.fn(() => { + throw new Error("Campaign unavailable"); + }) as unknown as typeof Campaign; + + expect(isBeaconSupported()).toBe(false); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/chat.test.ts b/ChatSetAttr/src/__tests__/unit/chat.test.ts new file mode 100644 index 0000000000..6f1bb69f50 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/chat.test.ts @@ -0,0 +1,471 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + getPlayerName, + sendMessages, + sendErrors, + sendDelayMessage, + sendBeaconUnsupportedNotice, + normalizeCommandOutputOptions, + BEACON_UNSUPPORTED_NOTICE_TITLE, + BEACON_UNSUPPORTED_NOTICE_BODY, + LONG_RUNNING_QUERY_TITLE, + LONG_RUNNING_QUERY_BODY, +} from "../../modules/chat"; + +// Mock the templates +vi.mock("../../templates/messages", () => ({ + createChatMessage: vi.fn(), + createErrorMessage: vi.fn(), +})); + +vi.mock("../../templates/notice", () => ({ + createNoticeMessage: vi.fn(), +})); + +// Mock Roll20 globals +const mockPlayer = { + get: vi.fn(), +}; + +const mockGetObj = vi.fn(); +const mockSendChat = vi.fn(); + +global.getObj = mockGetObj; +global.sendChat = mockSendChat; + +import { createChatMessage, createErrorMessage } from "../../templates/messages"; +import { createNoticeMessage } from "../../templates/notice"; +const mockCreateChatMessage = vi.mocked(createChatMessage); +const mockCreateErrorMessage = vi.mocked(createErrorMessage); +const mockCreateNoticeMessage = vi.mocked(createNoticeMessage); + +describe("chat", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getPlayerName", () => { + it("should return player display name when player exists", () => { + mockPlayer.get.mockReturnValue("John Doe"); + mockGetObj.mockReturnValue(mockPlayer); + + const result = getPlayerName("player123"); + + expect(mockGetObj).toHaveBeenCalledWith("player", "player123"); + expect(mockPlayer.get).toHaveBeenCalledWith("_displayname"); + expect(result).toBe("John Doe"); + }); + + it("should return undefined when player does not exist", () => { + mockGetObj.mockReturnValue(null); + + const result = getPlayerName("nonexistent"); + + expect(mockGetObj).toHaveBeenCalledWith("player", "nonexistent"); + expect(result).toBeUndefined(); + }); + + it("should return undefined when player exists but has no display name", () => { + mockPlayer.get.mockReturnValue(null); + mockGetObj.mockReturnValue(mockPlayer); + + const result = getPlayerName("player456"); + + expect(mockGetObj).toHaveBeenCalledWith("player", "player456"); + expect(mockPlayer.get).toHaveBeenCalledWith("_displayname"); + expect(result).toBeUndefined(); + }); + + it("should return undefined when player exists but display name is undefined", () => { + mockPlayer.get.mockReturnValue(undefined); + mockGetObj.mockReturnValue(mockPlayer); + + const result = getPlayerName("player789"); + + expect(result).toBeUndefined(); + }); + + it("should return undefined when player has empty display name", () => { + mockPlayer.get.mockReturnValue(""); + mockGetObj.mockReturnValue(mockPlayer); + + const result = getPlayerName("player101"); + + expect(result).toBeUndefined(); + }); + + it("should handle display names with special characters", () => { + mockPlayer.get.mockReturnValue("Player-42_Test!"); + mockGetObj.mockReturnValue(mockPlayer); + + const result = getPlayerName("player102"); + + expect(result).toBe("Player-42_Test!"); + }); + + it("should handle display names with spaces", () => { + mockPlayer.get.mockReturnValue(" Spaced Name "); + mockGetObj.mockReturnValue(mockPlayer); + + const result = getPlayerName("player103"); + + expect(result).toBe(" Spaced Name "); + }); + }); + + describe("sendMessages", () => { + beforeEach(() => { + mockPlayer.get.mockReturnValue("Test Player"); + mockGetObj.mockReturnValue(mockPlayer); + mockCreateChatMessage.mockReturnValue("formatted-chat-message"); + }); + + it("should send a whispered chat message to the player", () => { + const messages = ["Message 1", "Message 2"]; + + sendMessages("player123", "Test Header", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("Test Header", messages); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); + }); + + it("should handle empty messages array", () => { + const messages: string[] = []; + + sendMessages("player123", "Empty Header", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("Empty Header", messages); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); + }); + + it("should handle single message", () => { + const messages = ["Single message"]; + + sendMessages("player123", "Single Header", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("Single Header", messages); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); + }); + + it("should handle messages with special characters", () => { + const messages = ["Message with \"quotes\"", "Message with \"apostrophes\"", "Message with "]; + + sendMessages("player123", "Special Header", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("Special Header", messages); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); + }); + + it("should whisper to GM when player is unknown", () => { + mockGetObj.mockReturnValue(null); + const messages = ["Test message"]; + + sendMessages("unknown-player", "Test Header", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("Test Header", messages); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"GM\" formatted-chat-message"); + }); + + it("should handle player names with quotes", () => { + mockPlayer.get.mockReturnValue("Player \"Nickname\" Smith"); + mockGetObj.mockReturnValue(mockPlayer); + const messages = ["Test message"]; + + sendMessages("player123", "Test Header", messages); + + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Player \"Nickname\" Smith\" formatted-chat-message"); + }); + + it("should handle empty header", () => { + const messages = ["Test message"]; + + sendMessages("player123", "", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("", messages); + }); + + it("should handle long message arrays", () => { + const messages = Array.from({ length: 100 }, (_, i) => `Message ${i + 1}`); + + sendMessages("player123", "Long Header", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("Long Header", messages); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); + }); + }); + + describe("sendErrors", () => { + beforeEach(() => { + mockPlayer.get.mockReturnValue("Test Player"); + mockGetObj.mockReturnValue(mockPlayer); + mockCreateErrorMessage.mockReturnValue("formatted-error-message"); + }); + + it("should not send message when errors array is empty", () => { + const errors: string[] = []; + + sendErrors("player123", "Error Header", errors); + + expect(mockCreateErrorMessage).not.toHaveBeenCalled(); + expect(mockSendChat).not.toHaveBeenCalled(); + }); + + it("should send error message when errors exist", () => { + const errors = ["Error 1", "Error 2"]; + + sendErrors("player123", "Error Header", errors); + + expect(mockCreateErrorMessage).toHaveBeenCalledWith("Error Header", errors); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-error-message"); + }); + + it("should handle single error", () => { + const errors = ["Single error"]; + + sendErrors("player123", "Error Header", errors); + + expect(mockCreateErrorMessage).toHaveBeenCalledWith("Error Header", errors); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-error-message"); + }); + + it("should handle errors with special characters", () => { + const errors = ["Error with \"quotes\"", "Error with ", "Error with & symbols"]; + + sendErrors("player123", "Special Error Header", errors); + + expect(mockCreateErrorMessage).toHaveBeenCalledWith("Special Error Header", errors); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-error-message"); + }); + + it("should whisper errors to GM when player is unknown", () => { + mockGetObj.mockReturnValue(null); + const errors = ["Test error"]; + + sendErrors("unknown-player", "Error Header", errors); + + expect(mockCreateErrorMessage).toHaveBeenCalledWith("Error Header", errors); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"GM\" formatted-error-message"); + }); + + it("should handle empty header", () => { + const errors = ["Test error"]; + + sendErrors("player123", "", errors); + + expect(mockCreateErrorMessage).toHaveBeenCalledWith("", errors); + }); + + it("should handle long error arrays", () => { + const errors = Array.from({ length: 50 }, (_, i) => `Error ${i + 1}`); + + sendErrors("player123", "Many Errors", errors); + + expect(mockCreateErrorMessage).toHaveBeenCalledWith("Many Errors", errors); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-error-message"); + }); + + it("should handle player names with special whisper characters", () => { + mockPlayer.get.mockReturnValue("Player@123"); + mockGetObj.mockReturnValue(mockPlayer); + const errors = ["Test error"]; + + sendErrors("player123", "Error Header", errors); + + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Player@123\" formatted-error-message"); + }); + }); + + describe("normalizeCommandOutputOptions", () => { + it("should treat mute as silent for feedback and delay", () => { + expect(normalizeCommandOutputOptions({ mute: true })).toEqual({ + mute: true, + silent: true, + }); + }); + + it("should keep silent-only distinct from mute for errors", () => { + expect(normalizeCommandOutputOptions({ silent: true })).toEqual({ + mute: false, + silent: true, + }); + }); + }); + + describe("feedback delivery", () => { + beforeEach(() => { + mockPlayer.get.mockReturnValue("Test Player"); + mockGetObj.mockReturnValue(mockPlayer); + mockCreateChatMessage.mockReturnValue("formatted-chat-message"); + mockCreateErrorMessage.mockReturnValue("formatted-error-message"); + }); + + it("should default sender to ChatSetAttr when delivery is omitted", () => { + sendMessages("player123", "Test Header", ["Message"]); + + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); + }); + + it("should send public feedback without a whisper prefix", () => { + sendMessages("player123", "Test Header", ["Message"], { public: true }); + + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "formatted-chat-message"); + expect(mockSendChat.mock.calls[0][1]).not.toMatch(/^\/w /); + }); + + it("should send public feedback from a custom sender", () => { + sendMessages("player123", "Test Header", ["Message"], { from: "Wizard", public: true }); + + expect(mockSendChat).toHaveBeenCalledWith("Wizard", "formatted-chat-message"); + expect(mockSendChat.mock.calls[0][1]).not.toMatch(/^\/w /); + }); + + it("should whisper feedback from a custom sender when not public", () => { + sendMessages("player123", "Test Header", ["Message"], { from: "Wizard" }); + + expect(mockSendChat).toHaveBeenCalledWith("Wizard", "/w \"Test Player\" formatted-chat-message"); + }); + + it("should default error sender to ChatSetAttr when from is undefined", () => { + sendErrors("player123", "Errors", ["Something went wrong"], undefined); + + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-error-message"); + }); + + it("should always whisper errors even with a custom sender", () => { + sendErrors("player123", "Errors", ["Something went wrong"], "Wizard"); + + expect(mockSendChat).toHaveBeenCalledWith("Wizard", "/w \"Test Player\" formatted-error-message"); + }); + }); + + describe("command output suppression", () => { + beforeEach(() => { + mockPlayer.get.mockReturnValue("Test Player"); + mockGetObj.mockReturnValue(mockPlayer); + mockCreateChatMessage.mockReturnValue("formatted-chat-message"); + mockCreateErrorMessage.mockReturnValue("formatted-error-message"); + mockCreateNoticeMessage.mockReturnValue("notice-message"); + }); + + it("should suppress errors when mute is set", () => { + sendErrors("player123", "Errors", ["No valid targets found."], "ChatSetAttr", { mute: true, silent: true }); + + expect(mockCreateErrorMessage).not.toHaveBeenCalled(); + expect(mockSendChat).not.toHaveBeenCalled(); + }); + + it("should still send errors when only silent is set", () => { + sendErrors("player123", "Errors", ["No valid targets found."], "ChatSetAttr", { mute: false, silent: true }); + + expect(mockCreateErrorMessage).toHaveBeenCalled(); + expect(mockSendChat).toHaveBeenCalled(); + }); + + it("should suppress success messages when silent is set", () => { + sendMessages("player123", "Setting Attributes", ["Set attribute"], undefined, { mute: false, silent: true }); + + expect(mockCreateChatMessage).not.toHaveBeenCalled(); + expect(mockSendChat).not.toHaveBeenCalled(); + }); + + it("should suppress success messages when mute is set", () => { + sendMessages("player123", "Setting Attributes", ["Set attribute"], undefined, { mute: true, silent: true }); + + expect(mockCreateChatMessage).not.toHaveBeenCalled(); + expect(mockSendChat).not.toHaveBeenCalled(); + }); + + it("should whisper delay notice to the command runner", () => { + sendDelayMessage("player123"); + + expect(mockCreateNoticeMessage).toHaveBeenCalledWith( + LONG_RUNNING_QUERY_TITLE, + LONG_RUNNING_QUERY_BODY, + ); + expect(mockSendChat).toHaveBeenCalledWith( + "ChatSetAttr", + '/w "Test Player" notice-message', + undefined, + { noarchive: true }, + ); + }); + + it("should whisper Beacon unsupported notice to the GM", () => { + sendBeaconUnsupportedNotice(); + + expect(mockCreateNoticeMessage).toHaveBeenCalledWith( + BEACON_UNSUPPORTED_NOTICE_TITLE, + BEACON_UNSUPPORTED_NOTICE_BODY, + ); + expect(mockSendChat).toHaveBeenCalledWith( + "ChatSetAttr", + "/w gm notice-message", + undefined, + { noarchive: true }, + ); + }); + + it("should suppress delay notice when mute is set", () => { + sendDelayMessage("player123", { mute: true, silent: true }); + + expect(mockCreateNoticeMessage).not.toHaveBeenCalled(); + expect(mockSendChat).not.toHaveBeenCalled(); + }); + }); + + describe("integration scenarios", () => { + beforeEach(() => { + mockCreateChatMessage.mockReturnValue("chat-message"); + mockCreateErrorMessage.mockReturnValue("error-message"); + }); + + it("should handle multiple calls with same player", () => { + mockPlayer.get.mockReturnValue("Consistent Player"); + mockGetObj.mockReturnValue(mockPlayer); + + sendMessages("player123", "Header 1", ["Message 1"]); + sendErrors("player123", "Error Header", ["Error 1"]); + sendMessages("player123", "Header 2", ["Message 2"]); + + expect(mockSendChat).toHaveBeenCalledTimes(3); + expect(mockSendChat).toHaveBeenNthCalledWith(1, "ChatSetAttr", "/w \"Consistent Player\" chat-message"); + expect(mockSendChat).toHaveBeenNthCalledWith(2, "ChatSetAttr", "/w \"Consistent Player\" error-message"); + expect(mockSendChat).toHaveBeenNthCalledWith(3, "ChatSetAttr", "/w \"Consistent Player\" chat-message"); + }); + + it("should handle different players in sequence", () => { + const mockPlayer1 = { get: vi.fn().mockReturnValue("Player One") }; + const mockPlayer2 = { get: vi.fn().mockReturnValue("Player Two") }; + + mockGetObj.mockImplementation((type, id) => { + if (id === "player1") return mockPlayer1; + if (id === "player2") return mockPlayer2; + return null; + }); + + sendMessages("player1", "Header", ["Message for P1"]); + sendMessages("player2", "Header", ["Message for P2"]); + + expect(mockSendChat).toHaveBeenNthCalledWith(1, "ChatSetAttr", "/w \"Player One\" chat-message"); + expect(mockSendChat).toHaveBeenNthCalledWith(2, "ChatSetAttr", "/w \"Player Two\" chat-message"); + }); + + it("should handle mixed success and error scenarios", () => { + mockPlayer.get.mockReturnValue("Mixed Player"); + mockGetObj.mockReturnValue(mockPlayer); + + // Send success message first + sendMessages("player123", "Success", ["Operation completed"]); + + // Try to send empty error (should not send) + sendErrors("player123", "No Errors", []); + + // Send actual error + sendErrors("player123", "Real Error", ["Something went wrong"]); + + expect(mockSendChat).toHaveBeenCalledTimes(2); // Only success and real error + expect(mockSendChat).toHaveBeenNthCalledWith(1, "ChatSetAttr", "/w \"Mixed Player\" chat-message"); + expect(mockSendChat).toHaveBeenNthCalledWith(2, "ChatSetAttr", "/w \"Mixed Player\" error-message"); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/commands.test.ts b/ChatSetAttr/src/__tests__/unit/commands.test.ts new file mode 100644 index 0000000000..2d717249b8 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/commands.test.ts @@ -0,0 +1,545 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Attribute } from "../../types"; +import { + setattr, + modattr, + modbattr, + resetattr, + delattr, + handlers, +} from "../../modules/commands"; +import { getAttributes } from "../../modules/attributes"; + +// Mock the attributes module +vi.mock("../../modules/attributes", () => ({ + getAttributes: vi.fn(), +})); + +const mockGetAttributes = vi.mocked(getAttributes); + +const feedbackMock = { public: false }; + +describe("commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("setattr", () => { + it("should set current values for attributes", async () => { + const changes: Attribute[] = [ + { name: "strength", current: 15 }, + { name: "dexterity", current: 12 }, + ]; + + const result = await setattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 15, + dexterity: 12, + }); + expect(result.errors).toEqual([]); + }); + + it("should set max values for attributes", async () => { + const changes: Attribute[] = [ + { name: "hp", max: 25 }, + { name: "mp", max: 15 }, + ]; + + const result = await setattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp_max: 25, + mp_max: 15, + }); + expect(result.errors).toEqual([]); + }); + + it("should set both current and max values", async () => { + const changes: Attribute[] = [ + { name: "hp", current: 20, max: 25 }, + ]; + + const result = await setattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 20, + hp_max: 25, + }); + }); + + it("should skip attributes without names", async () => { + const changes: Attribute[] = [ + { current: 15 }, // no name + { name: "strength", current: 16 }, + ]; + + const result = await setattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 16, + }); + }); + + it("should handle string and boolean values", async () => { + const changes: Attribute[] = [ + { name: "name", current: "Gandalf" }, + { name: "active", current: true }, + ]; + + const result = await setattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + name: "Gandalf", + active: true, + }); + }); + }); + + describe("modattr", () => { + beforeEach(() => { + mockGetAttributes.mockResolvedValue({ + strength: 10, + hp: 15, + hp_max: 20, + }); + }); + + it("should modify current values with addition", async () => { + const changes: Attribute[] = [ + { name: "strength", current: "+5" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 15, + }); + }); + + it("should modify current values with subtraction", async () => { + const changes: Attribute[] = [ + { name: "hp", current: "-3" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 12, + }); + }); + + it("should modify current values with multiplication", async () => { + const changes: Attribute[] = [ + { name: "strength", current: "*2" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 20, + }); + }); + + it("should modify current values with division", async () => { + const changes: Attribute[] = [ + { name: "hp", current: "/3" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 5, + }); + }); + + it("should handle division by zero safely", async () => { + const changes: Attribute[] = [ + { name: "hp", current: "/0" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 15, // original value unchanged + }); + }); + + it("should modify max values", async () => { + const changes: Attribute[] = [ + { name: "hp", max: "+5" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp_max: 25, + }); + }); + + it("should handle absolute values (no operator)", async () => { + const changes: Attribute[] = [ + { name: "strength", current: 18 }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 28, // 10 + 18 (treated as addition) + }); + }); + + it("should handle undefined base values", async () => { + mockGetAttributes.mockResolvedValue({}); + + const changes: Attribute[] = [ + { name: "newattr", current: "+5" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + newattr: 5, + }); + }); + + it("should skip attributes without names", async () => { + const changes: Attribute[] = [ + { current: "+5" }, // no name + { name: "strength", current: "+2" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 12, + }); + }); + }); + + describe("modbattr", () => { + beforeEach(() => { + mockGetAttributes.mockResolvedValue({ + hp: 15, + hp_max: 20, + mp: 8, + mp_max: 10, + }); + }); + + it("should modify current value and enforce bounds", async () => { + const changes: Attribute[] = [ + { name: "hp", current: "+10" }, // current goes to 25, max stays 20 + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 20, + }); + }); + + it("should modify max value and adjust current if needed", async () => { + const changes: Attribute[] = [ + { name: "hp", max: "-5" }, // max becomes 15, current is 15 + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp_max: 15, + hp: 15, // current bounded by new max + }); + }); + + it("should modify both current and max values", async () => { + const changes: Attribute[] = [ + { name: "mp", current: "+5", max: "+5" }, // current: 13, max: 15 + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + mp: 13, + mp_max: 15, + }); + }); + + it("should handle case where current exceeds new max", async () => { + const changes: Attribute[] = [ + { name: "hp", max: "-10" }, // max becomes 10, current is 15 + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp_max: 10, + hp: 10, // current reduced to new max + }); + }); + + it("should handle undefined max values gracefully", async () => { + mockGetAttributes.mockResolvedValue({ + newattr: 5, + }); + + const changes: Attribute[] = [ + { name: "newattr", current: "+3" }, + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + newattr: 8, // no max constraint + }); + }); + + it("should skip attributes without names", async () => { + const changes: Attribute[] = [ + { current: "+5", max: "+5" }, // no name + { name: "hp", current: "+1" }, + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 16, + }); + }); + }); + + describe("resetattr", () => { + beforeEach(() => { + mockGetAttributes.mockResolvedValue({ + hp: 10, + hp_max: 25, + mp: 5, + mp_max: 15, + strength: 12, // no max value + }); + }); + + it("should reset current to max value", async () => { + const changes: Attribute[] = [ + { name: "hp" }, + { name: "mp" }, + ]; + + const result = await resetattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 25, + mp: 15, + }); + }); + + it("should reset to 0 when no max value exists", async () => { + const changes: Attribute[] = [ + { name: "strength" }, + ]; + + const result = await resetattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 0, + }); + }); + + it("should skip attributes without names", async () => { + const changes: Attribute[] = [ + {}, // no name + { name: "hp" }, + ]; + + const result = await resetattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 25, + }); + }); + + it("should handle mixed scenarios", async () => { + const changes: Attribute[] = [ + { name: "hp" }, // has max + { name: "strength" }, // no max + ]; + + const result = await resetattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 25, + strength: 0, + }); + }); + }); + + describe("delattr", () => { + it("should mark attributes for deletion", async () => { + const changes: Attribute[] = [ + { name: "oldattr" }, + { name: "tempattr" }, + ]; + + const result = await delattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + oldattr: undefined, + oldattr_max: undefined, + tempattr: undefined, + tempattr_max: undefined, + }); + expect(result.errors).toEqual([]); + }); + + it("should skip attributes without names", async () => { + const changes: Attribute[] = [ + {}, // no name + { name: "validattr" }, + ]; + + const result = await delattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + validattr: undefined, + validattr_max: undefined, + }); + }); + + it("should handle empty changes array", async () => { + const changes: Attribute[] = []; + + const result = await delattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({}); + expect(result.errors).toEqual([]); + }); + }); + + describe("handlers dictionary", () => { + it("should contain all command handlers", () => { + expect(handlers).toHaveProperty("setattr", setattr); + expect(handlers).toHaveProperty("modattr", modattr); + expect(handlers).toHaveProperty("modbattr", modbattr); + expect(handlers).toHaveProperty("resetattr", resetattr); + expect(handlers).toHaveProperty("delattr", delattr); + }); + + it("should have correct handler signatures", () => { + expect(typeof handlers.setattr).toBe("function"); + expect(typeof handlers.modattr).toBe("function"); + expect(typeof handlers.modbattr).toBe("function"); + expect(typeof handlers.resetattr).toBe("function"); + expect(typeof handlers.delattr).toBe("function"); + }); + }); + + describe("edge cases and error handling", () => { + it("should handle NaN values in modifications", async () => { + mockGetAttributes.mockResolvedValue({ + attr: "not-a-number", + }); + + const changes: Attribute[] = [ + { name: "attr", current: "+invalid" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.errors).toContain("Attribute 'attr' is not number-valued and so cannot be modified."); + }); + + it("should handle very large numbers", async () => { + mockGetAttributes.mockResolvedValue({ + bignum: 1000000, + }); + + const changes: Attribute[] = [ + { name: "bignum", current: "*1000" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + bignum: 1000000000, + }); + }); + + it("should handle negative results in bounded attributes", async () => { + mockGetAttributes.mockResolvedValue({ + resource: 5, + resource_max: 10, + }); + + const changes: Attribute[] = [ + { name: "resource", current: "-20" }, // would go to -15 + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + resource: 0, + }); + }); + }); + + describe("integration scenarios", () => { + beforeEach(() => { + mockGetAttributes.mockResolvedValue({ + hp: 15, + hp_max: 20, + mp: 8, + mp_max: 10, + strength: 14, + dexterity: 12, + }); + }); + + it("should handle multiple attributes in a single command", async () => { + const changes: Attribute[] = [ + { name: "hp", current: "+5" }, + { name: "mp", current: "-2" }, + { name: "strength", current: "*1.5" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 20, + mp: 6, + strength: 21, // 14 * 1.5 = 21 + }); + }); + + it("should handle attribute queries with referenced attributes", async () => { + mockGetAttributes.mockImplementation((target, attributeNames) => { + const allAttrs = { + hp: 15, + hp_max: 20, + mp: 8, + mp_max: 10, + strength: 14, + referenced_attr: 5, + }; + + const result: Record = {}; + if (Array.isArray(attributeNames)) { + for (const name of attributeNames) { + result[name] = allAttrs[name as keyof typeof allAttrs]; + } + } + return Promise.resolve(result); + }); + + const changes: Attribute[] = [ + { name: "hp", current: "+2" }, + ]; + + const result = await modattr(changes, "char1", ["referenced_attr"], false, feedbackMock); + + expect(mockGetAttributes).toHaveBeenCalledWith("char1", expect.arrayContaining(["referenced_attr", "hp"])); + expect(result.result).toEqual({ + hp: 17, + }); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/config.test.ts b/ChatSetAttr/src/__tests__/unit/config.test.ts new file mode 100644 index 0000000000..2267afcc2d --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/config.test.ts @@ -0,0 +1,651 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + checkGlobalConfig, + getConfig, + getPersistedSchemaVersion, + getStateSchemaVersion, + persistStateVersionMetadata, + setConfig, + syncScriptVersion, +} from "../../modules/config"; + +const GLOBAL_CONFIG_LABELS = { + playersCanModify: "Players can modify all characters", + playersCanEvaluate: "Players can use --evaluate", + useWorkers: "Trigger sheet workers when setting attributes", + playersCanTargetParty: "Players can target party members", +} as const; + +function buildGlobalConfig( + options: Partial>, + lastsaved = 2000, +) { + const chatsetattr: Record = { lastsaved }; + for (const [key, label] of Object.entries(GLOBAL_CONFIG_LABELS)) { + chatsetattr[label] = options[key as keyof typeof GLOBAL_CONFIG_LABELS] ? key : ""; + } + return { chatsetattr }; +} + +describe("config", () => { + beforeEach(() => { + vi.clearAllMocks(); + global.globalconfig = {}; + global.state = { + ChatSetAttr: {}, + }; + }); + + describe("getStateSchemaVersion", () => { + it("should return 0 for missing values", () => { + expect(getStateSchemaVersion(undefined)).toBe(0); + expect(getStateSchemaVersion(null)).toBe(0); + }); + + it("should return numeric schema versions as-is", () => { + expect(getStateSchemaVersion(3)).toBe(3); + expect(getStateSchemaVersion(4)).toBe(4); + }); + + it("should return numeric strings as schema versions", () => { + expect(getStateSchemaVersion("3")).toBe(3); + expect(getStateSchemaVersion("4")).toBe(4); + }); + + it("should return 0 for non-numeric version strings", () => { + expect(getStateSchemaVersion("1.10")).toBe(0); + expect(getStateSchemaVersion("2.0")).toBe(0); + }); + }); + + describe("getPersistedSchemaVersion", () => { + it("should return 0 when version is missing", () => { + global.state.ChatSetAttr = { + flags: ["welcome"], + helpContentUpdatedAt: 123, + }; + expect(getPersistedSchemaVersion()).toBe(0); + }); + + it("should return persisted schema version when present", () => { + global.state.ChatSetAttr = { version: 3 }; + expect(getPersistedSchemaVersion()).toBe(3); + + global.state.ChatSetAttr = { version: 4 }; + expect(getPersistedSchemaVersion()).toBe(4); + }); + }); + + describe("persistStateVersionMetadata", () => { + it("should persist only scriptVersion when schema version is missing", () => { + global.state.ChatSetAttr = { + flags: ["welcome"], + helpContentUpdatedAt: 1781273463973, + useWorkers: false, + }; + + persistStateVersionMetadata(); + + expect(Object.hasOwn(global.state.ChatSetAttr, "version")).toBe(false); + expect(global.state.ChatSetAttr.scriptVersion).toBe("2.0"); + }); + + it("should persist normalized schema version and scriptVersion when version is stored as a string", () => { + global.state.ChatSetAttr = { version: "3" }; + + persistStateVersionMetadata(); + + expect(global.state.ChatSetAttr.version).toBe(3); + expect(global.state.ChatSetAttr.scriptVersion).toBe("2.0"); + }); + + it("should persist scriptVersion when schema version is already stored", () => { + global.state.ChatSetAttr = { version: 4 }; + + persistStateVersionMetadata(); + + expect(global.state.ChatSetAttr.version).toBe(4); + expect(global.state.ChatSetAttr.scriptVersion).toBe("2.0"); + }); + }); + + describe("syncScriptVersion", () => { + it("should persist script.json version in state", () => { + global.state.ChatSetAttr = { scriptVersion: "1.10" }; + + syncScriptVersion(); + + expect(global.state.ChatSetAttr.scriptVersion).toBe("2.0"); + }); + + it("should not write state when script version and schema version are already current", () => { + global.state.ChatSetAttr = { version: 4, scriptVersion: "2.0" }; + const before = { ...global.state.ChatSetAttr }; + + syncScriptVersion(); + + expect(global.state.ChatSetAttr).toEqual(before); + }); + }); + + describe("getConfig", () => { + it("should return default config when no state config exists", () => { + // Clear the state entirely + global.state = {}; + + const config = getConfig(); + + expect(config).toEqual({ + version: 4, + scriptVersion: "2.0", + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + helpContentUpdatedAt: 0, + flags: [] + }); + }); + + it("should return default config when ChatSetAttr state is undefined", () => { + global.state = { ChatSetAttr: undefined }; + + + const config = getConfig(); + + expect(config).toEqual({ + version: 4, + scriptVersion: "2.0", + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + helpContentUpdatedAt: 0, + flags: [] + }); + }); + + it("should return default config when config property is undefined", () => { + global.state = { ChatSetAttr: {} }; + + const config = getConfig(); + + expect(config).toEqual({ + version: 4, + scriptVersion: "2.0", + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + helpContentUpdatedAt: 0, + flags: [] + }); + }); + + it("should merge state config with default config", () => { + global.state.ChatSetAttr = { + playersCanModify: true, + playersCanEvaluate: true + }; + + const config = getConfig(); + + expect(config).toEqual({ + version: 4, + scriptVersion: "2.0", + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: true, + helpContentUpdatedAt: 0, + flags: [] + }); + }); + + it("should override default values with state values", () => { + global.state.ChatSetAttr = { + version: 4, + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: false, + globalconfigCache: { + lastsaved: 1234567890 + } + }; + + const config = getConfig(); + + expect(config).toEqual({ + version: 4, + scriptVersion: "2.0", + globalconfigCache: { + lastsaved: 1234567890 + }, + playersCanTargetParty: true, + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: false, + helpContentUpdatedAt: 0, + flags: [] + }); + }); + + it("should handle partial globalconfigCache override", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 9876543210 + } + }; + + const config = getConfig(); + + expect(config.globalconfigCache).toEqual({ + lastsaved: 9876543210 + }); + expect(config.version).toBe(4); + expect(config.playersCanModify).toBe(false); + }); + + it("should handle extra properties in state config", () => { + global.state.ChatSetAttr = { + playersCanModify: true, + extraProperty: "should be included", + anotherExtra: 42 + }; + + const config = getConfig(); + + expect(config).toEqual({ + version: 4, + scriptVersion: "2.0", + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: true, + playersCanEvaluate: false, + useWorkers: true, + helpContentUpdatedAt: 0, + flags: [], + extraProperty: "should be included", + anotherExtra: 42 + }); + }); + + it("should return a new object each time (not reference to state)", () => { + const config1 = getConfig(); + const config2 = getConfig(); + + expect(config1).not.toBe(config2); + expect(config1).toEqual(config2); + }); + }); + + describe("setConfig", () => { + it("should set config in empty state", () => { + global.state = {}; + + setConfig({ playersCanModify: true }); + + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache).toBeUndefined(); + }); + + it("should initialize ChatSetAttr when undefined", () => { + global.state = { ChatSetAttr: undefined }; + + setConfig({ playersCanEvaluate: true }); + + expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache).toBeUndefined(); + }); + + it("should merge new config with existing config", () => { + global.state.ChatSetAttr = { + playersCanModify: true, + version: 2, + }; + + setConfig({ playersCanEvaluate: true }); + + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.version).toBe(2); + expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(true); + }); + + it("should override existing config values", () => { + global.state.ChatSetAttr = { + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + }; + + setConfig({ + playersCanModify: true, + useWorkers: false, + }); + + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(false); + expect(global.state.ChatSetAttr.useWorkers).toBe(false); + }); + + it("should not modify globalconfigCache unless explicitly provided", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 1000, + }, + }; + + setConfig({ playersCanModify: true }); + + expect(global.state.ChatSetAttr.globalconfigCache).toEqual({ + lastsaved: 1000, + }); + }); + + it("should preserve other ChatSetAttr properties", () => { + global.state = { + ChatSetAttr: { + playersCanModify: false, + otherProperty: "should be preserved", + anotherProp: 123 + } + }; + + + setConfig({ playersCanEvaluate: true }); + + expect(global.state.ChatSetAttr.playersCanModify).toBe(false); + expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(true); + expect(global.state.ChatSetAttr.otherProperty).toBe("should be preserved"); + expect(global.state.ChatSetAttr.anotherProp).toBe(123); + }); + + it("should handle empty config object", () => { + global.state.ChatSetAttr = { + playersCanModify: true, + globalconfigCache: { + lastsaved: 500, + }, + }; + + setConfig({}); + + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache).toEqual({ lastsaved: 500 }); + }); + + it("should handle null and undefined values", () => { + global.state.ChatSetAttr = { + playersCanModify: true, + }; + + setConfig({ + playersCanEvaluate: null, + useWorkers: undefined, + version: 0, + }); + + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(null); + expect(global.state.ChatSetAttr.useWorkers).toBe(undefined); + expect(global.state.ChatSetAttr.version).toBe(0); + }); + + it("should handle various data types", () => { + setConfig({ + stringProp: "test", + numberProp: 42, + booleanProp: true, + arrayProp: [1, 2, 3], + objectProp: { nested: "value" }, + nullProp: null, + undefinedProp: undefined, + }); + + expect(global.state.ChatSetAttr.stringProp).toBe("test"); + expect(global.state.ChatSetAttr.numberProp).toBe(42); + expect(global.state.ChatSetAttr.booleanProp).toBe(true); + expect(global.state.ChatSetAttr.arrayProp).toEqual([1, 2, 3]); + expect(global.state.ChatSetAttr.objectProp).toEqual({ nested: "value" }); + expect(global.state.ChatSetAttr.nullProp).toBe(null); + expect(global.state.ChatSetAttr.undefinedProp).toBe(undefined); + }); + }); + + describe("checkGlobalConfig", () => { + it("should do nothing when globalconfig is missing", () => { + global.globalconfig = {}; + + const changes = checkGlobalConfig(); + + expect(changes).toEqual([]); + expect(global.sendChat).not.toHaveBeenCalled(); + }); + + it("should do nothing when globalconfig timestamp is stale", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 2000, + [GLOBAL_CONFIG_LABELS.useWorkers]: "useWorkers", + }, + playersCanModify: true, + }; + global.globalconfig = buildGlobalConfig({ useWorkers: true }, 1000); + + const changes = checkGlobalConfig(); + + expect(changes).toEqual([]); + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.sendChat).not.toHaveBeenCalled(); + }); + + it("should update cache but not runtime when script is re-saved without option changes", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 1000, + [GLOBAL_CONFIG_LABELS.playersCanModify]: "", + [GLOBAL_CONFIG_LABELS.playersCanEvaluate]: "", + [GLOBAL_CONFIG_LABELS.useWorkers]: "useWorkers", + [GLOBAL_CONFIG_LABELS.playersCanTargetParty]: "", + }, + playersCanModify: true, + }; + global.globalconfig = buildGlobalConfig({ useWorkers: true }, 2000); + + const changes = checkGlobalConfig(); + + expect(changes).toEqual([]); + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe(2000); + expect(global.sendChat).not.toHaveBeenCalled(); + }); + + it("should import changed page values and notify the GM", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 1000, + [GLOBAL_CONFIG_LABELS.playersCanModify]: "", + [GLOBAL_CONFIG_LABELS.playersCanEvaluate]: "", + [GLOBAL_CONFIG_LABELS.useWorkers]: "useWorkers", + [GLOBAL_CONFIG_LABELS.playersCanTargetParty]: "", + }, + }; + global.globalconfig = buildGlobalConfig( + { playersCanModify: true, useWorkers: true }, + 2000, + ); + + const changes = checkGlobalConfig(); + + expect(changes).toEqual(["playersCanModify: false → true"]); + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe(2000); + expect(global.sendChat).toHaveBeenCalledTimes(1); + expect(vi.mocked(global.sendChat).mock.calls[0][1]).toContain("/w gm "); + expect(vi.mocked(global.sendChat).mock.calls[0][1]).toContain("playersCanModify: false → true"); + }); + + it("should import all changed options on first load", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 0, + }, + }; + global.globalconfig = buildGlobalConfig( + { + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: true, + playersCanTargetParty: true, + }, + 1500, + ); + + const changes = checkGlobalConfig(); + + expect(changes).toEqual([ + "playersCanModify: false → true", + "playersCanEvaluate: false → true", + ]); + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(true); + expect(getConfig().playersCanTargetParty).toBe(true); + expect(getConfig().useWorkers).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe(1500); + }); + + it("should import the fourth globalconfig option", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 1000, + [GLOBAL_CONFIG_LABELS.playersCanTargetParty]: "", + }, + playersCanTargetParty: false, + }; + global.globalconfig = buildGlobalConfig({ playersCanTargetParty: true }, 2000); + + const changes = checkGlobalConfig(); + + expect(changes).toEqual(["playersCanTargetParty: false → true"]); + expect(global.state.ChatSetAttr.playersCanTargetParty).toBe(true); + }); + + it("should not modify globalconfigCache.lastsaved when using setConfig", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 1234, + }, + }; + + setConfig({ playersCanModify: true }); + + expect(global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe(1234); + }); + }); + + describe("integration tests", () => { + it("should work correctly when setting and getting config", () => { + // Start with empty state + global.state = {}; + + + // Set some config + setConfig({ + playersCanModify: true, + version: 5 + }); + + // Get config should return defaults merged with set values + const config = getConfig(); + + expect(config.version).toBe(5); + expect(config.playersCanModify).toBe(true); + expect(config.playersCanEvaluate).toBe(false); + expect(config.playersCanTargetParty).toBe(true); + expect(config.useWorkers).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache).toBeUndefined(); + }); + + it("should handle multiple setConfig calls", () => { + global.state = {}; + + + setConfig({ playersCanModify: true }); + setConfig({ playersCanEvaluate: true }); + setConfig({ useWorkers: false }); + + const config = getConfig(); + + expect(config.playersCanModify).toBe(true); + expect(config.playersCanEvaluate).toBe(true); + expect(config.useWorkers).toBe(false); + expect(config.version).toBe(4); // Default value + }); + + it("should handle overriding previously set values", () => { + global.state = {}; + + + setConfig({ playersCanModify: true }); + expect(getConfig().playersCanModify).toBe(true); + + setConfig({ playersCanModify: false }); + expect(getConfig().playersCanModify).toBe(false); + }); + + it("should maintain state persistence between calls", () => { + global.state = {}; + + + setConfig({ playersCanModify: true }); + setConfig({ playersCanEvaluate: true }); + + // Both values should persist + const config = getConfig(); + expect(config.playersCanModify).toBe(true); + expect(config.playersCanEvaluate).toBe(true); + }); + }); + + describe("edge cases", () => { + it("should handle when state is null", () => { + // @ts-expect-error we're deliberately setting to null + global.state = null; + + + expect(() => getConfig()).not.toThrow(); + }); + + it("should handle when state is undefined", () => { + // @ts-expect-error we're deliberately setting to undefined + global.state = undefined; + + + expect(() => getConfig()).not.toThrow(); + }); + + it("should handle circular references in setConfig", () => { + global.state = {}; + + + const circularObj: Record = { a: 1 }; + circularObj.self = circularObj; + + expect(() => setConfig(circularObj)).not.toThrow(); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/feedback.test.ts b/ChatSetAttr/src/__tests__/unit/feedback.test.ts new file mode 100644 index 0000000000..aac9adc8d7 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/feedback.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect } from "vitest"; +import { + createFeedbackMessage, + formatDeleteFeedback, + formatSettingFeedback, +} from "../../modules/feedback"; +import type { Attribute, AttributeRecord, FeedbackObject } from "../../types"; + +const characterName = "The Aaron 2014"; + +describe("createFeedbackMessage", () => { + const mockStartingValues: AttributeRecord = { + hp: 10, + hp_max: 20, + strength: 15, + strength_max: 18 + }; + + const mockTargetValues: AttributeRecord = { + hp: 25, + hp_max: 30, + strength: 16, + strength_max: 20 + }; + + it("should return empty string when feedback is undefined", () => { + const result = createFeedbackMessage("John", undefined, mockStartingValues, mockTargetValues); + expect(result).toBe(""); + }); + + it("should return feedback content when no placeholders exist", () => { + const feedback: FeedbackObject = { public: false, content: "Simple message" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Simple message"); + }); + + it("should replace _CHARNAME_ with character name", () => { + const feedback: FeedbackObject = { public: false, content: "Hello _CHARNAME_!" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Hello John!"); + }); + + it("should replace _NAME0_ with first attribute name", () => { + const feedback: FeedbackObject = { public: false, content: "Changed _NAME0_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Changed hp"); + }); + + it("should replace _TCUR0_ with starting current value", () => { + const feedback: FeedbackObject = { public: false, content: "From _TCUR0_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("From 10"); + }); + + it("should replace _TMAX0_ with starting max value", () => { + const feedback: FeedbackObject = { public: false, content: "Max was _TMAX0_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Max was 20"); + }); + + it("should replace _CUR0_ with target current value", () => { + const feedback: FeedbackObject = { public: false, content: "Now _CUR0_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Now 25"); + }); + + it("should replace _MAX0_ with target max value", () => { + const feedback: FeedbackObject = { public: false, content: "Max now _MAX0_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Max now 30"); + }); + + it("should handle multiple placeholders in one message", () => { + const feedback: FeedbackObject = { + public: false, + content: "_CHARNAME_: _NAME0_ changed from _TCUR0_ to _CUR0_ (max: _TMAX0_ to _MAX0_)" + }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("John: hp changed from 10 to 25 (max: 20 to 30)"); + }); + + it("should handle different attribute indices", () => { + const feedback: FeedbackObject = { public: false, content: "_NAME1_ is _CUR1_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("strength is 16"); + }); + + it("should return empty string for invalid attribute index", () => { + const feedback: FeedbackObject = { public: false, content: "_NAME99_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe(""); + }); + + it("should fall back to starting max when max was not modified", () => { + const startingValues: AttributeRecord = { hp: 7, hp_max: 119 }; + const targetValues: AttributeRecord = { hp: 10 }; + const feedback: FeedbackObject = { + public: false, + content: "_NAME0_ was _TCUR0_/_TMAX0_ now _CUR0_/_MAX0_ for _CHARNAME_", + }; + const result = createFeedbackMessage("The Aaron 2014", feedback, startingValues, targetValues); + expect(result).toBe("hp was 7/119 now 10/119 for The Aaron 2014"); + }); + + it("should fall back to starting current when current was not modified", () => { + const startingValues: AttributeRecord = { hp: 7, hp_max: 119 }; + const targetValues: AttributeRecord = { hp_max: 125 }; + const feedback: FeedbackObject = { public: false, content: "_CUR0_/_MAX0_" }; + const result = createFeedbackMessage("John", feedback, startingValues, targetValues); + expect(result).toBe("7/125"); + }); + + it("should handle missing max attributes gracefully", () => { + const limitedStarting: AttributeRecord = { hp: 10 }; + const limitedTarget: AttributeRecord = { hp: 25 }; + const feedback: FeedbackObject = { public: false, content: "_TMAX0_ to _MAX0_" }; + const result = createFeedbackMessage("John", feedback, limitedStarting, limitedTarget); + expect(result).toBe(" to "); + }); + + it("should handle empty target values", () => { + const feedback: FeedbackObject = { public: false, content: "_NAME0_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, {}); + expect(result).toBe(""); + }); + + it("should handle complex message with multiple attributes", () => { + const feedback: FeedbackObject = { + public: false, + content: "_CHARNAME_ updated: _NAME0_: _TCUR0_→_CUR0_, _NAME1_: _TCUR1_→_CUR1_" + }; + const result = createFeedbackMessage("Alice", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Alice updated: hp: 10→25, strength: 15→16"); + }); +}); + +describe("formatSettingFeedback", () => { + it("should format max-only syntax", () => { + const changes: Attribute[] = [{ name: "hp", max: "48" }]; + const result: AttributeRecord = { hp_max: "48" }; + expect(formatSettingFeedback(characterName, changes, result)).toBe( + "Setting hp to 48 (max) for character The Aaron 2014.", + ); + }); + + it("should format current and max", () => { + const changes: Attribute[] = [{ name: "hp", current: "13", max: "63" }]; + const result: AttributeRecord = { hp: "13", hp_max: "63" }; + expect(formatSettingFeedback(characterName, changes, result)).toBe( + "Setting hp to 13 / 63 for character The Aaron 2014.", + ); + }); + + it("should format multiple attributes in command order", () => { + const changes: Attribute[] = [ + { name: "hp", current: "13", max: "63" }, + { name: "ac", current: "18" }, + { name: "speed", current: "50" }, + ]; + const result: AttributeRecord = { + hp: "13", + hp_max: "63", + ac: "18", + speed: "50", + }; + expect(formatSettingFeedback(characterName, changes, result)).toBe( + "Setting hp to 13 / 63, ac to 18, speed to 50 for character The Aaron 2014.", + ); + }); + + it("should format current-only changes", () => { + const changes: Attribute[] = [{ name: "ac", current: "18" }]; + const result: AttributeRecord = { ac: "18" }; + expect(formatSettingFeedback(characterName, changes, result)).toBe( + "Setting ac to 18 for character The Aaron 2014.", + ); + }); + + it("should show (empty) for empty string values", () => { + const changes: Attribute[] = [{ name: "notes", current: "" }]; + const result: AttributeRecord = { notes: "" }; + expect(formatSettingFeedback(characterName, changes, result)).toBe( + "Setting notes to (empty) for character The Aaron 2014.", + ); + }); + + it("should omit attributes not present in the filtered result", () => { + const changes: Attribute[] = [{ name: "hp", current: "13", max: "63" }]; + const result: AttributeRecord = { hp: "13" }; + expect(formatSettingFeedback(characterName, changes, result)).toBe( + "Setting hp to 13 for character The Aaron 2014.", + ); + }); + + it("should return null when nothing was set", () => { + const changes: Attribute[] = [{ name: "hp", current: "13" }]; + expect(formatSettingFeedback(characterName, changes, {})).toBeNull(); + }); +}); + +describe("formatDeleteFeedback", () => { + it("should format deleted attribute names", () => { + const changes: Attribute[] = [{ name: "hp" }, { name: "ac" }]; + const result: AttributeRecord = { hp: undefined, hp_max: undefined, ac: undefined }; + expect(formatDeleteFeedback(characterName, changes, result)).toBe( + "Deleting attribute(s) hp, ac for character The Aaron 2014.", + ); + }); + + it("should return null when no attributes were deleted", () => { + const changes: Attribute[] = [{ name: "hp" }]; + expect(formatDeleteFeedback(characterName, changes, {})).toBeNull(); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/help.test.ts b/ChatSetAttr/src/__tests__/unit/help.test.ts new file mode 100644 index 0000000000..7c97119aa2 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/help.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import { getConfig } from "../../modules/config"; +import { + applyHelpContentToHandout, + checkHelpMessage, + handleHelpCommand, + syncHelpHandoutOnStartup, +} from "../../modules/help"; +import { getBundledHelpContentUpdatedAt } from "../../templates/help/loadContentRevision"; + +vi.mock("../../templates/help/index", () => ({ + createHelpHandout: vi.fn(() => "
    help content
    "), +})); + +describe("help", () => { + const bundledAt = getBundledHelpContentUpdatedAt(); + const mockSet = vi.fn(); + + function mockHandout(id = "handout-1") { + return { id, set: mockSet }; + } + + beforeEach(() => { + vi.clearAllMocks(); + global.state = { ChatSetAttr: {} }; + global.findObjs = vi.fn(() => []); + global.createObj = vi.fn((_type, props) => mockHandout("new-handout")); + }); + + describe("checkHelpMessage", () => { + it("should accept !setattr-help", () => { + expect(checkHelpMessage("!setattr-help")).toBe(true); + expect(checkHelpMessage(" !setattr-help extra")).toBe(true); + }); + + it("should reject !setattrs-help", () => { + expect(checkHelpMessage("!setattrs-help")).toBe(false); + }); + }); + + describe("syncHelpHandoutOnStartup", () => { + it("should do nothing when the help handout does not exist", () => { + syncHelpHandoutOnStartup(); + + expect(global.findObjs).toHaveBeenCalled(); + expect(mockSet).not.toHaveBeenCalled(); + expect(getConfig().helpContentUpdatedAt).toBe(0); + }); + + it("should not update when state revision is current", () => { + global.findObjs = vi.fn(() => [mockHandout()]); + global.state = { ChatSetAttr: { helpContentUpdatedAt: bundledAt } }; + + syncHelpHandoutOnStartup(); + + expect(mockSet).not.toHaveBeenCalled(); + expect(getConfig().helpContentUpdatedAt).toBe(bundledAt); + }); + + it("should update when state revision is older than bundled revision", () => { + const handout = mockHandout(); + global.findObjs = vi.fn(() => [handout]); + global.state = { ChatSetAttr: { helpContentUpdatedAt: 0 } }; + + syncHelpHandoutOnStartup(); + + expect(mockSet).toHaveBeenCalledWith({ + inplayerjournals: "all", + notes: "
    help content
    ", + }); + expect(getConfig().helpContentUpdatedAt).toBe(bundledAt); + }); + }); + + describe("handleHelpCommand", () => { + it("should create a handout when none exists", () => { + handleHelpCommand(); + + expect(global.createObj).toHaveBeenCalledWith("handout", { + name: "ChatSetAttr Help", + }); + expect(mockSet).toHaveBeenCalledWith({ + inplayerjournals: "all", + notes: "
    help content
    ", + }); + expect(getConfig().helpContentUpdatedAt).toBe(bundledAt); + }); + + it("should update an existing handout without creating a duplicate", () => { + const handout = mockHandout("existing-handout"); + global.findObjs = vi.fn(() => [handout]); + + handleHelpCommand(); + + expect(global.createObj).not.toHaveBeenCalled(); + expect(mockSet).toHaveBeenCalledWith({ + inplayerjournals: "all", + notes: "
    help content
    ", + }); + expect(getConfig().helpContentUpdatedAt).toBe(bundledAt); + }); + }); + + describe("applyHelpContentToHandout", () => { + it("should write rendered content and persist bundled revision in state", () => { + const handout = mockHandout("apply-handout"); + + applyHelpContentToHandout(handout as Roll20Handout); + + expect(mockSet).toHaveBeenCalledWith({ + inplayerjournals: "all", + notes: "
    help content
    ", + }); + expect(getConfig().helpContentUpdatedAt).toBe(bundledAt); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/helpContent.test.ts b/ChatSetAttr/src/__tests__/unit/helpContent.test.ts new file mode 100644 index 0000000000..8c6a44b8e4 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/helpContent.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; + +import { loadHelpDocument } from "../../templates/help/loadContent"; +import { renderHelpHtml } from "../../templates/help/renderHtml"; +import { renderHelpMarkdown } from "../../templates/help/renderMarkdown"; +import type { HelpDocument } from "../../templates/help/types"; + +const fixture: HelpDocument = { + title: "Fixture Help", + introduction: "Intro with `code` and **bold**.", + sections: [ + { + id: "sample-section", + title: "Sample Section", + blocks: [ + { type: "paragraph", text: "Placeholder `--fb-from ` and `<<1d6>>`." }, + { type: "codeBlock", lines: ["&{template:default} {{name=Test}}"] }, + { type: "unorderedList", items: ["First item", "Second with `code`"] }, + { + type: "orderedList", + items: [ + { text: "Step one" }, + { + text: "Step two with example", + codeBlock: { lines: ["!setattr --sel --hp|25"] }, + }, + ], + }, + { type: "note", text: "A regular note.", emphasis: false }, + { type: "note", text: "An emphasized note.", emphasis: true }, + ], + subsections: [ + { + id: "nested", + title: "Nested Subsection", + blocks: [{ type: "paragraph", text: "Subsection body." }], + }, + ], + }, + { + id: "second-section", + title: "Second Section", + blocks: [{ type: "paragraph", text: "More content." }], + }, + ], +}; + +describe("help content loaders", () => { + it("should load a document with required fields", () => { + const doc = loadHelpDocument(); + + expect(typeof doc.title).toBe("string"); + expect(doc.title.length).toBeGreaterThan(0); + expect(typeof doc.introduction).toBe("string"); + expect(Array.isArray(doc.sections)).toBe(true); + expect(doc.sections.length).toBeGreaterThan(0); + + for (const section of doc.sections) { + expect(typeof section.id).toBe("string"); + expect(section.id.length).toBeGreaterThan(0); + expect(typeof section.title).toBe("string"); + expect(section.title.length).toBeGreaterThan(0); + } + }); +}); + +describe("help content rendering", () => { + it("should render markdown with a table of contents when requested", () => { + const markdown = renderHelpMarkdown(fixture, { includeToc: true }); + + expect(markdown).toContain("## Table of Contents"); + expect(markdown).toContain("1. [Sample Section](#sample-section)"); + expect(markdown).toContain("2. [Second Section](#second-section)"); + expect(markdown).toContain("## Sample Section"); + expect(markdown).toContain("### Nested Subsection"); + }); + + it("should render markdown without a table of contents", () => { + const markdown = renderHelpMarkdown(fixture, { includeToc: false }); + + expect(markdown).not.toContain("## Table of Contents"); + expect(markdown.startsWith("# Fixture Help")).toBe(true); + }); + + it("should render markdown block types from fixture content", () => { + const markdown = renderHelpMarkdown(fixture, { includeToc: false }); + + expect(markdown).toContain("Placeholder `--fb-from ` and `<<1d6>>`."); + expect(markdown).toContain("&{template:default} {{name=Test}}"); + expect(markdown).toContain("- First item"); + expect(markdown).toContain("1. Step one"); + expect(markdown).toContain("> **Note:** An emphasized note."); + expect(markdown).toContain("> A regular note."); + }); + + it("should escape literal angle brackets in handout HTML", () => { + const html = renderHelpHtml(fixture, "test-handout-id"); + + expect(html).toContain("--fb-from <NAME>"); + expect(html).toContain("<<1d6>>"); + expect(html).not.toContain("--fb-from "); + }); + + it("should preserve roll template syntax in handout HTML", () => { + const html = renderHelpHtml(fixture, "test-handout-id"); + + expect(html).toContain("&{template:default}"); + expect(html).toContain("journal.roll20.net/handout/test-handout-id/#Sample%20Section"); + }); + + it("should render ordered list code blocks and notes in handout HTML", () => { + const html = renderHelpHtml(fixture, "test-handout-id"); + + expect(html).toContain("!setattr --sel --hp|25"); + expect(html).toContain("Note:"); + expect(html).toContain("Nested Subsection"); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/helpers.test.ts b/ChatSetAttr/src/__tests__/unit/helpers.test.ts new file mode 100644 index 0000000000..a8180cbdbf --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/helpers.test.ts @@ -0,0 +1,82 @@ +import { it, expect, describe } from "vitest"; +import { toStringOrUndefined, calculateBoundValue, cleanValue } from "../../modules/helpers"; + +describe("toStringOrUndefined", () => { + it("returns undefined for undefined input", () => { + expect(toStringOrUndefined(undefined)).toBeUndefined(); + }); + + it("returns undefined for null input", () => { + expect(toStringOrUndefined(null)).toBeUndefined(); + }); + + it("converts numbers to strings", () => { + expect(toStringOrUndefined(42)).toBe("42"); + }); + + it("converts booleans to strings", () => { + expect(toStringOrUndefined(true)).toBe("true"); + expect(toStringOrUndefined(false)).toBe("false"); + }); + + it("returns strings unchanged", () => { + expect(toStringOrUndefined("hello")).toBe("hello"); + }); +}); + +describe("calculateBoundValue", () => { + it("returns 0 when value and max are undefined", () => { + expect(calculateBoundValue(undefined, undefined)).toBe(0); + }); + + it("returns the value when max is undefined", () => { + expect(calculateBoundValue(10, undefined)).toBe(10); + }); + + it("returns 0 when value is undefined", () => { + expect(calculateBoundValue(undefined, 20)).toBe(0); + }); + + it("returns the value when it is less than max", () => { + expect(calculateBoundValue(15, 20)).toBe(15); + }); + + it("returns max when value exceeds max", () => { + expect(calculateBoundValue(25, 20)).toBe(20); + }); +}); + +describe("cleanValue", () => { + it("trims whitespace from the value", () => { + expect(cleanValue(" hello world ")).toBe("hello world"); + }); + + it("removes surrounding single quotes", () => { + expect(cleanValue("'hello'")).toBe("hello"); + expect(cleanValue(" 'hello' ")).toBe("hello"); + }); + + it("removes surrounding double quotes", () => { + expect(cleanValue("\"hello\"")).toBe("hello"); + expect(cleanValue(" \"hello\" ")).toBe("hello"); + }); + + it("removes surrounding mixed quotes", () => { + expect(cleanValue("'hello\"")).toBe("hello"); + expect(cleanValue("\"hello'")).toBe("hello"); + }); + + it("maintains spacing within the value", () => { + expect(cleanValue(" ' hello world ' ")).toBe(" hello world "); + }); + + it("only replaces surrounding quotes", () => { + expect(cleanValue(" \"he'llo\" ")).toBe("he'llo"); + expect(cleanValue(" 'he\"llo' ")).toBe("he\"llo"); + }); + + it("handles symbols and special characters in quotes", () => { + expect(cleanValue("'@#$% special chars '")).toBe("@#$% special chars "); + expect(cleanValue("\"1234!@#$%^&*()_+\"")).toBe("1234!@#$%^&*()_+"); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/inlinerolls.test.ts b/ChatSetAttr/src/__tests__/unit/inlinerolls.test.ts new file mode 100644 index 0000000000..e5b0cf2b28 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/inlinerolls.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from "vitest"; +import { + normalizeTemplateRollProperties, + processInlinerolls, +} from "../../modules/inlinerolls"; + +function makeDiceRoll(total: number): RollData { + return { + expression: "3d6", + results: { + resultType: "sum", + total, + type: "V", + rolls: [{ + dice: 3, + sides: 6, + type: "R", + results: [{ v: 4 }, { v: 1 }, { v: 3 }], + }], + }, + rollid: "roll-1", + signature: "sig-1", + }; +} + +describe("processInlinerolls", () => { + it("should replace a simple inline roll placeholder with the roll total", () => { + const result = processInlinerolls({ + content: "!setattr --sel --hp|$[[0]]", + inlinerolls: [makeDiceRoll(11)], + }); + + expect(result).toBe("!setattr --sel --hp|11"); + }); + + it("should return content unchanged when inlinerolls are missing", () => { + const content = "!setattr --sel --hp|$[[0]]"; + expect(processInlinerolls({ content })).toBe(content); + expect(processInlinerolls({ content, inlinerolls: [] })).toBe(content); + }); + + it("should replace nested roll placeholders using the correct index", () => { + const result = processInlinerolls({ + content: "!! test $[[1]]", + inlinerolls: [ + { + expression: "1d2+4", + results: { + resultType: "sum", + total: 6, + type: "V", + rolls: [], + }, + rollid: "inner-roll", + signature: "sig-inner", + }, + makeDiceRoll(8), + ], + }); + + expect(result).toBe("!! test 8"); + }); + + it("should replace multiple placeholders in one command", () => { + const result = processInlinerolls({ + content: "!setattr --sel --hp|$[[0]] --temp|$[[1]]", + inlinerolls: [makeDiceRoll(11), makeDiceRoll(5)], + }); + + expect(result).toBe("!setattr --sel --hp|11 --temp|5"); + }); + + it("should use rollable table item names when present", () => { + const result = processInlinerolls({ + content: "!setattr --sel --result|$[[0]]", + inlinerolls: [{ + expression: "1d100", + results: { + resultType: "sum", + total: 42, + type: "V", + rolls: [{ + table: "table-id", + type: "R", + results: [ + { tableItem: { name: "Magic Sword" } }, + { tableItem: { name: "Healing Potion" } }, + ], + }], + }, + rollid: "table-roll", + signature: "sig-table", + }], + }); + + expect(result).toBe("!setattr --sel --result|Magic Sword, Healing Potion"); + }); + + it("should preserve a zero roll total", () => { + const result = processInlinerolls({ + content: "!setattr --sel --hp|$[[0]]", + inlinerolls: [makeDiceRoll(0)], + }); + + expect(result).toBe("!setattr --sel --hp|0"); + }); +}); + +function processCommandContent( + content: string, + inlinerolls?: RollData[], +): string { + return processInlinerolls({ + content: normalizeTemplateRollProperties(content), + inlinerolls, + }); +} + +describe("normalizeTemplateRollProperties", () => { + it("should unwrap template properties containing inline roll placeholders", () => { + const content = "!setattr --charid char1 --mod --hp|-{{damage=$[[0]]}}"; + expect(normalizeTemplateRollProperties(content)).toBe( + "!setattr --charid char1 --mod --hp|-$[[0]]", + ); + }); + + it("should unwrap resolved template property values", () => { + const content = "!setattr --charid char1 --hp|-{{damage=34}}"; + expect(normalizeTemplateRollProperties(content)).toBe( + "!setattr --charid char1 --hp|-34", + ); + }); +}); + +describe("template roll pipeline", () => { + it("should resolve mod damage from template inline roll placeholders", () => { + const result = processCommandContent( + "!setattr --charid char1 --mod --hp|-{{damage=$[[0]]}}", + [makeDiceRoll(34)], + ); + expect(result).toBe("!setattr --charid char1 --mod --hp|-34"); + }); + + it("should resolve setattr damage from template inline roll placeholders", () => { + const result = processCommandContent( + "!setattr --charid char1 --hp|-{{damage=$[[0]]}}", + [makeDiceRoll(34)], + ); + expect(result).toBe("!setattr --charid char1 --hp|-34"); + }); + + it("should resolve setattr damage from already-resolved template properties", () => { + const result = processCommandContent( + "!setattr --charid char1 --hp|-{{damage=34}}", + [makeDiceRoll(34)], + ); + expect(result).toBe("!setattr --charid char1 --hp|-34"); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/message.test.ts b/ChatSetAttr/src/__tests__/unit/message.test.ts new file mode 100644 index 0000000000..3ec8ee522d --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/message.test.ts @@ -0,0 +1,822 @@ +import { describe, it, expect } from "vitest"; +import { + extractMessageFromRollTemplate, + normalizeMultilineCommand, + parseMessage, +} from "../../modules/message"; + +// parseMessage returns undefined for empty/invalid commands; these tests pass +// valid commands, so unwrap the result for ergonomic property access. +function parse(content: string) { + const result = parseMessage(content); + if (!result) { + throw new Error(`parseMessage unexpectedly returned undefined for: ${content}`); + } + return result; +} + +describe("message", () => { + describe("extractMessageFromRollTemplate", () => { + const createMockMessage = (content: string): Roll20ChatMessage => ({ + content, + who: "TestPlayer", + type: "general", + playerid: "player123", + rolltemplate: undefined, + inlinerolls: undefined, + selected: undefined, + }); + + describe("valid command extraction", () => { + it("should extract setattr command", () => { + const msg = createMockMessage("&{template:default} {{name=Test}} {{setattr=!setattr --strength 18!!!}} {{other=content}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --strength 18"); + }); + + it("should extract modattr command", () => { + const msg = createMockMessage("&{template:default} {{modattr=!modattr --strength +2!!!}} {{other=content}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!modattr --strength +2"); + }); + + it("should extract modbattr command", () => { + const msg = createMockMessage("&{template:default} {{modbattr=!modbattr --hitpoints +5!!!}} {{other=content}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!modbattr --hitpoints +5"); + }); + + it("should extract resetattr command", () => { + const msg = createMockMessage("&{template:default} {{resetattr=!resetattr --strength!!!}} {{other=content}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!resetattr --strength"); + }); + + it("should extract delattr command", () => { + const msg = createMockMessage("&{template:default} {{delattr=!delattr --skill_athletics!!!}} {{other=content}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!delattr --skill_athletics"); + }); + }); + + describe("complex command extraction", () => { + it("should extract command with multiple options", () => { + const msg = createMockMessage("&{template:default} {{r1=[[1d20]]}} !setattr --sel --strength|18|20 --dexterity|14 --silent!!! {{r2=[[2d6]]}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --sel --strength|18|20 --dexterity|14 --silent"); + }); + + it("should extract command with placeholders", () => { + const msg = createMockMessage("&{template:default} {{r1=[[1d20]]}} !modattr --sel --hitpoints|+%constitution_modifier%!!! {{r2=[[2d6]]}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!modattr --sel --hitpoints|+%constitution_modifier%"); + }); + + it("should handle whitespace in commands", () => { + const msg = createMockMessage("&{template:default} {{r1=[[1d20]]}} !setattr --strength 18 !!! {{r2=[[2d6]]}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --strength 18"); + }); + }); + + describe("multiple commands", () => { + it("should extract first matching command when multiple are present", () => { + const msg = createMockMessage("{{setattr=!setattr --strength 18!!!}} {{modattr=!modattr --dex +2!!!}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --strength 18"); + }); + }); + + describe("invalid cases", () => { + it("should return false when no commands are found", () => { + const msg = createMockMessage("&{template:default} {{name=Test}} {{description=No commands here}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe(false); + }); + + it("should return false when command exists but no !!! terminator", () => { + const msg = createMockMessage("{{setattr=!setattr --strength 18}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe(false); + }); + + it("should return false when empty content", () => { + const msg = createMockMessage(""); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe(false); + }); + + it("should return false when command keyword exists but no actual command", () => { + const msg = createMockMessage("This message contains the word setattr but no command"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe(false); + }); + }); + + describe("edge cases", () => { + it("should handle commands with special characters", () => { + const msg = createMockMessage("{{setattr=!setattr --repeating_skills_CREATE_name|Acrobatics!!!}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --repeating_skills_CREATE_name|Acrobatics"); + }); + + it("should handle commands with numbers", () => { + const msg = createMockMessage("{{setattr=!setattr --skill_1|5 --skill_2|10!!!}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --skill_1|5 --skill_2|10"); + }); + + it("should handle commands with equals signs", () => { + const msg = createMockMessage("{{setattr=!setattr --strength|18 --formula|2+3=5!!!}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --strength|18 --formula|2+3=5"); + }); + }); + }); + + describe("parseMessage", () => { + describe("operation extraction", () => { + it("should extract setattr operation", () => { + const result = parse("!setattr --strength 18"); + expect(result.operation).toBe("setattr"); + }); + + it("should extract modattr operation", () => { + const result = parse("!modattr --strength +2"); + expect(result.operation).toBe("modattr"); + }); + + it("should extract modbattr operation", () => { + const result = parse("!modbattr --hitpoints +5"); + expect(result.operation).toBe("modbattr"); + }); + + it("should extract resetattr operation", () => { + const result = parse("!resetattr --strength"); + expect(result.operation).toBe("resetattr"); + }); + + it("should extract delattr operation", () => { + const result = parse("!delattr --skill_athletics"); + expect(result.operation).toBe("delattr"); + }); + + it("should return undefined for empty command", () => { + expect(parseMessage("")).toBeUndefined(); + }); + + it("should return undefined for invalid command", () => { + expect(parseMessage("!invalidcmd --test")).toBeUndefined(); + }); + + it("should extract command when extra tokens follow on the same segment", () => { + const result = parse("!setattr -all --hp|1"); + expect(result.operation).toBe("setattr"); + expect(result.changes).toEqual( + expect.arrayContaining([ + { name: "-all" }, + { name: "hp", current: "1" }, + ]), + ); + }); + + it("should extract command when multiple words follow before first --", () => { + const result = parse("!setattr foo bar --hp|1"); + expect(result.operation).toBe("setattr"); + expect(result.changes).toEqual( + expect.arrayContaining([ + { name: "foobar" }, + { name: "hp", current: "1" }, + ]), + ); + }); + }); + + describe("aberrant input", () => { + it("should return undefined when the command segment has no leading bang", () => { + expect(parseMessage("setattr --hp|1")).toBeUndefined(); + }); + + it("should return undefined when the first token is not a known command", () => { + expect(parseMessage("!notacommand --hp|1")).toBeUndefined(); + }); + + it("should parse setattr with only the operation and no segments", () => { + const result = parse("!setattr"); + expect(result.operation).toBe("setattr"); + expect(result.targeting).toEqual([]); + expect(result.changes).toEqual([]); + }); + + it("should treat single-dash tokens as attribute names, not options", () => { + const result = parse("!setattr -all --hp|1"); + expect(result.options).toEqual({}); + expect(result.targeting).toEqual([]); + }); + }); + + describe("command option overrides", () => { + it("should override setattr with mod option", () => { + const result = parse("!setattr --mod --strength +2"); + expect(result.operation).toBe("modattr"); + }); + + it("should override setattr with modb option", () => { + const result = parse("!setattr --modb --hitpoints +5"); + expect(result.operation).toBe("modbattr"); + }); + + it("should override setattr with reset option", () => { + const result = parse("!setattr --reset --strength"); + expect(result.operation).toBe("resetattr"); + }); + + it("should handle multiple command options (last one wins)", () => { + const result = parse("!setattr --mod --reset --strength"); + expect(result.operation).toBe("resetattr"); + }); + }); + + describe("options parsing", () => { + it("should parse silent option", () => { + const result = parse("!setattr --silent --strength 18"); + expect(result.options.silent).toBe(true); + }); + + it("should parse replace option", () => { + const result = parse("!setattr --replace --strength 18"); + expect(result.options.replace).toBe(true); + }); + + it("should parse nocreate option", () => { + const result = parse("!setattr --nocreate --strength 18"); + expect(result.options.nocreate).toBe(true); + }); + + it("should parse mute option", () => { + const result = parse("!setattr --mute --strength 18"); + expect(result.options.mute).toBe(true); + }); + + it("should parse evaluate option", () => { + const result = parse("!setattr --evaluate --strength 18"); + expect(result.options.evaluate).toBe(true); + }); + + it("should parse multiple options", () => { + const result = parse("!setattr --silent --replace --evaluate --strength 18"); + expect(result.options.silent).toBe(true); + expect(result.options.replace).toBe(true); + expect(result.options.evaluate).toBe(true); + expect(result.options.mute).toBeUndefined(); + }); + }); + + describe("target parsing", () => { + it("should parse all target", () => { + const result = parse("!setattr --all --strength 18"); + expect(result.targeting).toContain("all"); + }); + + it("should parse allgm target", () => { + const result = parse("!setattr --allgm --strength 18"); + expect(result.targeting).toContain("allgm"); + }); + + it("should parse allplayers target", () => { + const result = parse("!setattr --allplayers --strength 18"); + expect(result.targeting).toContain("allplayers"); + }); + + it("should parse charid target", () => { + const result = parse("!setattr --charid -Abc123 --strength 18"); + expect(result.targeting).toContain("charid -Abc123"); + }); + + it("should parse name target", () => { + const result = parse("!setattr --name Gandalf --strength 18"); + expect(result.targeting).toContain("name Gandalf"); + }); + + it("should preserve multi-word comma-separated name targeting", () => { + const result = parse("!setattr --name bob the slayer, timmy the weak --time|now"); + expect(result.targeting).toContain("name bob the slayer, timmy the weak"); + expect(result.changes).toEqual([{ name: "time", current: "now" }]); + }); + + it("should parse sel target", () => { + const result = parse("!setattr --sel --strength 18"); + expect(result.targeting).toContain("sel"); + }); + + it("should parse multiple targets", () => { + const result = parse("!setattr --sel --name Gandalf --strength 18"); + expect(result.targeting).toContain("sel"); + expect(result.targeting).toContain("name Gandalf"); + }); + }); + + describe("attribute changes parsing", () => { + it("should parse simple attribute name", () => { + const result = parse("!setattr --sel --strength"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toEqual({ name: "strength" }); + }); + + it("should parse attribute with current value", () => { + const result = parse("!setattr --sel --strength|18"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toEqual({ + name: "strength", + current: "18", + }); + }); + + it("should parse attribute with current and max values", () => { + const result = parse("!setattr --sel --strength|18|20"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toEqual({ + name: "strength", + current: "18", + max: "20", + }); + }); + + it("should parse attribute with empty current but max value", () => { + const result = parse("!setattr --sel --strength||20"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toEqual({ + name: "strength", + max: "20", + }); + }); + + it("should parse max-only syntax with trailing pipe segments", () => { + const result = parse("!setattr --sel --hp||23|43"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toEqual({ + name: "hp", + max: "23", + }); + expect(result.changes[0]).not.toHaveProperty("current"); + }); + + it("should parse multiple attributes", () => { + const result = parse("!setattr --sel --strength|18 --dexterity|14|16 --constitution"); + expect(result.changes).toHaveLength(3); + expect(result.changes[0]).toEqual({ name: "strength", current: "18" }); + expect(result.changes[1]).toEqual({ name: "dexterity", current: "14", max: "16" }); + expect(result.changes[2]).toEqual({ name: "constitution" }); + }); + + it("should handle attributes with numbers and underscores", () => { + const result = parse("!setattr --sel --skill_1|5 --attr_test_2|value"); + expect(result.changes).toHaveLength(2); + expect(result.changes[0]).toEqual({ name: "skill_1", current: "5" }); + expect(result.changes[1]).toEqual({ name: "attr_test_2", current: "value" }); + }); + + it("should preserve libUUID row ID characters in bare attribute names", () => { + const result = parse("!delattr --sel --repeating_weapons_-Def456"); + expect(result.changes).toEqual([{ name: "repeating_weapons_-Def456" }]); + }); + + it("should preserve mixed-case row ID tokens without leading hyphen", () => { + const result = parse("!delattr --sel --repeating_inventory_0ABCxyzZ"); + expect(result.changes).toEqual([{ name: "repeating_inventory_0ABCxyzZ" }]); + }); + }); + + describe("referenced attributes parsing", () => { + it("should extract references from current values", () => { + const result = parse("!setattr --sel --hitpoints|%constitution%"); + expect(result.references).toContain("%constitution%"); + }); + + it("should extract references from max values", () => { + const result = parse("!setattr --sel --hitpoints|10|%constitution%"); + expect(result.references).toContain("%constitution%"); + }); + + it("should extract multiple references from same attribute", () => { + const result = parse("!setattr --sel --total|%strength% + %dexterity%"); + expect(result.references).toContain("%strength%"); + expect(result.references).toContain("%dexterity%"); + }); + + it("should extract references from multiple attributes", () => { + const result = parse("!setattr --sel --hitpoints|%constitution%|%constitution_max% --armor|%dexterity%"); + expect(result.references).toContain("%constitution%"); + expect(result.references).toContain("%constitution_max%"); + expect(result.references).toContain("%dexterity%"); + }); + + it("should handle attributes with underscores and numbers in references", () => { + const result = parse("!setattr --sel --total|%skill_1% + %attr_test_2%"); + expect(result.references).toContain("%skill_1%"); + expect(result.references).toContain("%attr_test_2%"); + }); + + it("should not extract references from non-string values", () => { + const result = parse("!setattr --sel --strength|18"); + expect(result.references).toHaveLength(0); + }); + + it("should handle complex expressions with references", () => { + const result = parse("!setattr --sel --formula|%base% * 2 + %bonus%|%max_formula%"); + expect(result.references).toContain("%base%"); + expect(result.references).toContain("%bonus%"); + expect(result.references).toContain("%max_formula%"); + }); + }); + + describe("complex parsing scenarios", () => { + it("should parse command with all components", () => { + const result = parse("!setattr --silent --replace --sel --name Gandalf --strength|%base_str%|20 --dexterity|14"); + + expect(result.operation).toBe("setattr"); + expect(result.options.silent).toBe(true); + expect(result.options.replace).toBe(true); + expect(result.targeting).toContain("sel"); + expect(result.targeting).toContain("name Gandalf"); + expect(result.changes).toHaveLength(2); + expect(result.changes[0]).toEqual({ name: "strength", current: "%base_str%", max: "20" }); + expect(result.changes[1]).toEqual({ name: "dexterity", current: "14" }); + expect(result.references).toContain("%base_str%"); + }); + + it("should handle mixed command options and regular options", () => { + const result = parse("!setattr --mod --silent --evaluate --sel --strength|%base% + 2"); + + expect(result.operation).toBe("modattr"); + expect(result.options.silent).toBe(true); + expect(result.options.evaluate).toBe(true); + expect(result.targeting).toContain("sel"); + expect(result.changes[0]).toEqual({ name: "strength", current: "%base% + 2" }); + expect(result.references).toContain("%base%"); + }); + }); + + describe("edge cases", () => { + it("should handle extra whitespace", () => { + const result = parse(" !setattr --sel --strength | 18 "); + expect(result.operation).toBe("setattr"); + expect(result.targeting).toContain("sel"); + expect(result.changes[0]).toEqual({ name: "strength", current: "18" }); // Properly parsed with pipes + }); + + it("should ignore empty parts from double separators", () => { + const result = parse("!setattr --sel ---- --strength|18"); + expect(result.operation).toBe("setattr"); + expect(result.targeting).toContain("sel"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toEqual({ name: "strength", current: "18" }); + }); + + it("should handle attribute names with pipes but no values", () => { + const result = parse("!setattr --sel --test|"); + expect(result.changes[0]).toEqual({ name: "test" }); + }); + + it("should handle attributes with multiple pipes", () => { + const result = parse("!setattr --sel --test|val1|val2|val3"); + expect(result.changes[0]).toEqual({ + name: "test", + current: "val1", + max: "val2", // Only first two values after pipe are used + }); + }); + }); + + describe("feedback parsing", () => { + it("should parse fb-public option", () => { + const result = parse("!setattr --sel --fb-public --strength|18"); + expect(result.feedback.public).toBe(true); + }); + + it("should parse fb-from option with single word value", () => { + const result = parse("!setattr --sel --fb-from TestGM --strength|18"); + expect(result.feedback.from).toBe("TestGM"); + expect(result.feedback.public).toBe(false); // default + }); + + it("should parse fb-header option with single word value", () => { + const result = parse("!setattr --sel --fb-header Custom --strength|18"); + expect(result.feedback.header).toBe("Custom"); + expect(result.feedback.public).toBe(false); // default + }); + + it("should parse fb-content option with single word value", () => { + const result = parse("!setattr --sel --fb-content Custom --strength|18"); + expect(result.feedback.content).toBe("Custom"); + expect(result.feedback.public).toBe(false); // default + }); + + it("should parse multiple feedback options", () => { + const result = parse("!setattr --sel --fb-public --fb-from TestGM --fb-header Custom --strength|18"); + expect(result.feedback.public).toBe(true); + expect(result.feedback.from).toBe("TestGM"); + expect(result.feedback.header).toBe("Custom"); + expect(result.feedback.content).toBeUndefined(); + }); + + it("should parse all feedback options together", () => { + const result = parse("!setattr --sel --fb-public --fb-from TestGM --fb-header Test --fb-content Message --strength|18"); + expect(result.feedback.public).toBe(true); + expect(result.feedback.from).toBe("TestGM"); + expect(result.feedback.header).toBe("Test"); + expect(result.feedback.content).toBe("Message"); + }); + + it("should handle feedback options with no value gracefully", () => { + const result = parse("!setattr --sel --fb-from --strength|18"); + expect(result.feedback.from).toBe(""); + expect(result.feedback.public).toBe(false); + }); + + it("should default feedback to public false when no feedback options", () => { + const result = parse("!setattr --sel --strength|18"); + expect(result.feedback.public).toBe(false); + expect(result.feedback.from).toBeUndefined(); + expect(result.feedback.header).toBeUndefined(); + expect(result.feedback.content).toBeUndefined(); + }); + + it("should handle mixed feedback and regular options", () => { + const result = parse("!setattr --silent --fb-public --fb-from TestGM --sel --strength|18"); + expect(result.options.silent).toBe(true); + expect(result.feedback.public).toBe(true); + expect(result.feedback.from).toBe("TestGM"); + expect(result.targeting).toContain("sel"); + }); + }); + + describe("return value structure", () => { + it("should return all expected properties", () => { + const result = parse("!setattr --sel --strength|18"); + + expect(result).toHaveProperty("operation"); + expect(result).toHaveProperty("options"); + expect(result).toHaveProperty("targeting"); + expect(result).toHaveProperty("changes"); + expect(result).toHaveProperty("references"); + expect(result).toHaveProperty("feedback"); + + expect(typeof result.operation).toBe("string"); + expect(typeof result.options).toBe("object"); + expect(Array.isArray(result.targeting)).toBe(true); + expect(Array.isArray(result.changes)).toBe(true); + expect(Array.isArray(result.references)).toBe(true); + expect(typeof result.feedback).toBe("object"); + }); + + it("should return empty arrays when no matches found", () => { + const result = parse("!setattr"); + + expect(result.targeting).toEqual([]); + expect(result.changes).toEqual([]); + expect(result.references).toEqual([]); + expect(result.options).toEqual({}); + expect(result.feedback).toEqual({ public: false }); + }); + }); + + describe("quote handling and space stripping", () => { + describe("feedback option quote handling", () => { + it("strips single quotes from fb-header value", () => { + const message = "!setattr --fb-header 'Terrible Wounds'"; + const result = parse(message); + expect(result.feedback.header).toBe("Terrible Wounds"); + }); + + it("strips double quotes from fb-header value", () => { + const message = "!setattr --fb-header \"Terrible Wounds\""; + const result = parse(message); + expect(result.feedback.header).toBe("Terrible Wounds"); + }); + + it("preserves trailing spaces within quotes for fb-header", () => { + const message = "!setattr --fb-header \"Terrible Wounds \""; + const result = parse(message); + expect(result.feedback.header).toBe("Terrible Wounds "); + }); + + it("strips trailing spaces without quotes for fb-header", () => { + const message = "!setattr --fb-header Terrible"; + const result = parse(message); + expect(result.feedback.header).toBe("Terrible"); + }); + + it("strips single quotes from fb-content value", () => { + const message = "!setattr --fb-content 'Character'"; + const result = parse(message); + expect(result.feedback.content).toBe("Character"); + }); + + it("preserves internal quotes and spaces when enclosed in outer quotes", () => { + const message = "!setattr --fb-content 'Quote:'"; + const result = parse(message); + expect(result.feedback.content).toBe("Quote:"); + }); + + it("strips mixed quote types (single on one side, double on other)", () => { + const message = "!setattr --fb-from 'Player\""; + const result = parse(message); + expect(result.feedback.from).toBe("Player"); + }); + }); + + describe("attribute value quote handling", () => { + it("strips single quotes from attribute current value", () => { + const message = "!setattr --sel --hp|'25'"; + const result = parse(message); + expect(result.changes[0].current).toBe("25"); + }); + + it("strips single quotes from attribute max value", () => { + const message = "!setattr --sel --hp|10|'50'"; + const result = parse(message); + expect(result.changes[0].max).toBe("50"); + }); + + it("preserves trailing spaces within quotes for attribute values", () => { + const message = "!setattr --sel --details|'This is a long message with multiple words and some odd spacing '"; + const result = parse(message); + expect(result.changes[0].current).toBe("This is a long message with multiple words and some odd spacing "); + }); + + it("strips trailing spaces without quotes for attribute values", () => { + const message = "!setattr --sel --description|Some text with trailing spaces "; + const result = parse(message); + expect(result.changes[0].current).toBe("Some text with trailing spaces"); + }); + + it("handles quotes in both current and max values", () => { + const message = "!setattr --sel --stat|'Current Value '|'Max Value '"; + const result = parse(message); + expect(result.changes[0].current).toBe("Current Value "); + expect(result.changes[0].max).toBe("Max Value "); + }); + + it("strips double quotes from attribute values", () => { + const message = "!setattr --sel --name|\"Character Name\""; + const result = parse(message); + expect(result.changes[0].current).toBe("Character Name"); + }); + + it("handles empty values with quotes", () => { + const message = "!setattr --sel --empty|''"; + const result = parse(message); + expect(result.changes[0].current).toBe(""); + }); + + it("handles values with only spaces inside quotes", () => { + const message = "!setattr --sel --spaces|' '"; + const result = parse(message); + expect(result.changes[0].current).toBe(" "); + }); + }); + + describe("complex quote scenarios", () => { + it("handles multiple attributes with mixed quote usage", () => { + const message = "!setattr --sel --name|'John Doe' --hp|25 --description|'A brave warrior ' --ac|\"18\""; + const result = parse(message); + expect(result.changes).toHaveLength(4); + expect(result.changes[0].current).toBe("John Doe"); + expect(result.changes[1].current).toBe("25"); + expect(result.changes[2].current).toBe("A brave warrior "); + expect(result.changes[3].current).toBe("18"); + }); + + it("handles nested quotes correctly", () => { + const message = "!setattr --sel --speech|'He said \"Hello there!\" loudly'"; + const result = parse(message); + expect(result.changes[0].current).toBe("He said \"Hello there!\" loudly"); + }); + + it("handles quotes with special characters and spaces", () => { + const message = "!setattr --sel --special|'@^$% special chars '"; + const result = parse(message); + expect(result.changes[0].current).toBe("@^$% special chars "); + }); + }); + + describe("edge cases", () => { + it("handles single quote character as value", () => { + const message = "!setattr --sel --apostrophe|\"'\""; + const result = parse(message); + expect(result.changes[0].current).toBe("'"); + }); + + it("handles double quote character as value", () => { + const message = "!setattr --sel --quote|'\"'"; + const result = parse(message); + expect(result.changes[0].current).toBe("\""); + }); + + it("ignores unmatched quotes", () => { + const message = "!setattr --sel --unmatched|'missing end quote"; + const result = parse(message); + expect(result.changes[0].current).toBe("'missing end quote"); + }); + }); + }); + }); + + describe("normalizeMultilineCommand", () => { + const DIVINE_CONTENT = "Starting at 7th level, when a creature within 5 feet of you takes damage, you can use your reaction to magically substitute your own health for that of the target creature, causing that creature not to take the damage. Instead, you take the damage. This damage to you can't be reduced or prevented in any way."; + + const MULTILINE_API_STRING = "!setattr {{
    \n--sel
    \n--replace
    \n--repeating_classfeature_-create_name|Divine Allegience
    \n--repeating_classfeature_-create_content|" + DIVINE_CONTENT + "
    \n--repeating_classfeature_-create_content_toggle|1
    \n}}"; + + const FLAT_COMMAND = "!setattr --sel --replace --repeating_classfeature_-create_name|Divine Allegience --repeating_classfeature_-create_content|" + DIVINE_CONTENT + " --repeating_classfeature_-create_content_toggle|1"; + + it("strips
    and unwraps the trailing {{ }} block", () => { + const normalized = normalizeMultilineCommand(MULTILINE_API_STRING); + expect(normalized).not.toContain("
    "); + expect(normalized).not.toContain("{{"); + expect(normalized).not.toContain("}}"); + expect(normalized.startsWith("!setattr")).toBe(true); + }); + + it("leaves a plain single-line command unchanged", () => { + expect(normalizeMultilineCommand(FLAT_COMMAND)).toBe(FLAT_COMMAND); + }); + + it("unescapes escaped braces in the body", () => { + const message = "!setattr {{\n--sel\n--notes|value with \\{braces\\}\n}}"; + const normalized = normalizeMultilineCommand(message); + expect(normalized).toContain("value with {braces}"); + expect(normalized).not.toContain("\\{"); + expect(normalized).not.toContain("\\}"); + }); + + it("unwraps blocks that have trailing whitespace before }}", () => { + const message = "!setattr {{\n--sel\n--hp|1\n }}"; + const normalized = normalizeMultilineCommand(message); + const result = parseMessage(normalized); + expect(result?.operation).toBe("setattr"); + expect(result?.targeting).toContain("sel"); + }); + }); + + describe("parseMessage multiline commands", () => { + const DIVINE_CONTENT = "Starting at 7th level, when a creature within 5 feet of you takes damage, you can use your reaction to magically substitute your own health for that of the target creature, causing that creature not to take the damage. Instead, you take the damage. This damage to you can't be reduced or prevented in any way."; + + const MULTILINE_API_STRING = "!setattr {{
    \n--sel
    \n--replace
    \n--repeating_classfeature_-create_name|Divine Allegience
    \n--repeating_classfeature_-create_content|" + DIVINE_CONTENT + "
    \n--repeating_classfeature_-create_content_toggle|1
    \n}}"; + + const FLAT_COMMAND = "!setattr --sel --replace --repeating_classfeature_-create_name|Divine Allegience --repeating_classfeature_-create_content|" + DIVINE_CONTENT + " --repeating_classfeature_-create_content_toggle|1"; + + it("parses the API-shaped multiline command", () => { + const result = parse(MULTILINE_API_STRING); + expect(result.operation).toBe("setattr"); + expect(result.targeting).toContain("sel"); + expect(result.options.replace).toBe(true); + + const byName = Object.fromEntries( + result.changes + .filter(change => change.name) + .map(change => [change.name, change.current]), + ); + expect(byName["repeating_classfeature_-create_name"]).toBe("Divine Allegience"); + expect(byName["repeating_classfeature_-create_content"]).toBe(DIVINE_CONTENT); + expect(byName["repeating_classfeature_-create_content_toggle"]).toBe("1"); + }); + + it("produces the same result as the flat single-line command", () => { + const multiline = parse(MULTILINE_API_STRING); + const flat = parse(FLAT_COMMAND); + expect(multiline.operation).toBe(flat.operation); + expect(multiline.targeting).toEqual(flat.targeting); + expect(multiline.options).toEqual(flat.options); + expect(multiline.changes).toEqual(flat.changes); + }); + }); + + describe("multiline normalization preserves roll templates", () => { + const createMockMessage = (content: string): Roll20ChatMessage => ({ + content, + who: "TestPlayer", + type: "general", + playerid: "player123", + rolltemplate: undefined, + inlinerolls: undefined, + selected: undefined, + }); + + it("does not alter an extracted roll-template command", () => { + const msg = createMockMessage( + "&{template:default} {{name=Fireball Damage}} !setattr --mod --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}", + ); + const extracted = extractMessageFromRollTemplate(msg); + expect(extracted).not.toBe(false); + const normalized = normalizeMultilineCommand(extracted as string); + expect(normalized).toBe(extracted); + expect(normalized).toContain("{{damage=[[8d6]]}}"); + }); + + it("leaves an extracted damage property untouched (no unwrap)", () => { + const content = "!setattr --silent --mod --hp|-{{damage=$[[0]]}}"; + expect(normalizeMultilineCommand(content)).toBe(content); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/unit/modifications.test.ts b/ChatSetAttr/src/__tests__/unit/modifications.test.ts new file mode 100644 index 0000000000..c8dfd6345d --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/modifications.test.ts @@ -0,0 +1,443 @@ +import { describe, it, expect } from "vitest"; +import { + processModifierValue, + processModifierName, + processModifications, + type ProcessModifierNameOptions, +} from "../../modules/modifications"; +import type { Attribute, AttributeRecord, OptionsRecord } from "../../types"; + +describe("modifications", () => { + describe("processModifierValue", () => { + const mockAttributes: AttributeRecord = { + strength: 18, + dexterity: 14, + constitution: 16, + intelligence: 12, + wisdom: 13, + charisma: 10, + level: 5, + hitpoints: 45, + armorclass: 15, + name: "TestCharacter", + active: true, + }; + + describe("alias character replacement", () => { + it("should replace < with [ and > with ]", () => { + const result = processModifierValue("<<1d6>>", {}, { shouldAlias: true }); + expect(result).toBe("[[1d6]]"); + }); + + it("should replace ~ with -", () => { + const result = processModifierValue("~value", {}, { shouldAlias: true }); + expect(result).toBe("-value"); + }); + + it("should replace ; with ?", () => { + const result = processModifierValue(";{query}", {}, { shouldAlias: true }); + expect(result).toBe("?{query}"); + }); + + it("should replace ` with @", () => { + const result = processModifierValue("`{attribute}", {}, { shouldAlias: true }); + expect(result).toBe("@{attribute}"); + }); + + it("should replace multiple alias characters", () => { + const result = processModifierValue("<<1d6>>~;{query}+`{attribute}", {}, { shouldAlias: true }); + expect(result).toBe("[[1d6]]-?{query}+@{attribute}"); + }); + }); + + describe("placeholder replacement", () => { + it("should replace single placeholder with attribute value", () => { + const result = processModifierValue("%strength%", mockAttributes); + expect(result).toBe("18"); + }); + + it("should replace multiple placeholders", () => { + const result = processModifierValue("%strength% + %dexterity%", mockAttributes); + expect(result).toBe("18 + 14"); + }); + + it("should handle string attribute values", () => { + const result = processModifierValue("Hello %name%", mockAttributes); + expect(result).toBe("Hello TestCharacter"); + }); + + it("should handle boolean attribute values", () => { + const result = processModifierValue("Active: %active%", mockAttributes); + expect(result).toBe("Active: true"); + }); + + it("should leave placeholder unchanged if attribute not found", () => { + const result = processModifierValue("%nonexistent%", mockAttributes); + expect(result).toBe("%nonexistent%"); + }); + + it("should handle mixed existing and non-existing placeholders", () => { + const result = processModifierValue("%strength% + %nonexistent%", mockAttributes); + expect(result).toBe("18 + %nonexistent%"); + }); + + it("should handle placeholders with underscores and numbers", () => { + const attributes = { skill_1: 5, attr_test_2: "value" }; + const result = processModifierValue("%skill_1% and %attr_test_2%", attributes); + expect(result).toBe("5 and value"); + }); + + it("should handle empty attribute record", () => { + const result = processModifierValue("%strength%", {}); + expect(result).toBe("%strength%"); + }); + }); + + describe("evaluation", () => { + it("should not evaluate by default", () => { + const result = processModifierValue("2 + 3", {}); + expect(result).toBe("2 + 3"); + }); + + it("should evaluate when shouldEvaluate is true", () => { + const result = processModifierValue("2 + 3", {}, { shouldEvaluate: true }); + expect(result).toBe(5); + }); + + it("should evaluate with placeholder replacement", () => { + const result = processModifierValue("%strength% + %dexterity%", mockAttributes, { shouldEvaluate: true }); + expect(result).toBe(32); + }); + + it("should handle complex expressions", () => { + const result = processModifierValue("(5 + 3) * 2", {}, { shouldEvaluate: true }); + expect(result).toBe(16); + }); + + it("should return original value if evaluation fails", () => { + const result = processModifierValue("invalid expression +++", {}, { shouldEvaluate: true }); + expect(result).toBe("invalid expression +++"); + }); + }); + + describe("combined functionality", () => { + it("should handle alias replacement, placeholders, and evaluation together", () => { + const attributes = { base: 10, bonus: 5 }; + const result = processModifierValue("<%base%> + <%bonus%>", attributes, { shouldEvaluate: true, shouldAlias: true }); + expect(result).toBe("105"); // becomes "[10] + [5]" which evaluates to string concatenation + }); + + it("should process in correct order: aliases, then placeholders, then evaluation", () => { + const attributes = { test: 8 }; + const result = processModifierValue("<%test%> ~ 2", attributes, { shouldEvaluate: true, shouldAlias: true }); + expect(result).toBe(6); // [8] - 2 = 6 + }); + }); + + describe("edge cases", () => { + it("should handle empty string", () => { + const result = processModifierValue("", {}); + expect(result).toBe(""); + }); + + it("should handle string with only placeholders", () => { + const result = processModifierValue("%nonexistent%", {}); + expect(result).toBe("%nonexistent%"); + }); + + it("should handle string with only alias characters", () => { + const result = processModifierValue("<>~;`", {}, { shouldAlias: true }); + expect(result).toBe("[]-?@"); + }); + + it("should handle undefined attributes gracefully", () => { + const attributes = { test: undefined }; + const result = processModifierValue("%test%", attributes); + expect(result).toBe("%test%"); + }); + + it("should handle missing attributes as undefined", () => { + const attributes = {}; + const result = processModifierValue("%nonexistent%", attributes); + expect(result).toBe("%nonexistent%"); + }); + + it("should handle zero values", () => { + const attributes = { test: 0 }; + const result = processModifierValue("%test%", attributes); + expect(result).toBe("0"); + }); + + it("should handle false boolean values", () => { + const attributes = { test: false }; + const result = processModifierValue("%test%", attributes); + expect(result).toBe("false"); + }); + + it("should handle malformed placeholders", () => { + const result = processModifierValue("%incomplete", {}); + expect(result).toBe("%incomplete"); + }); + + it("should handle nested placeholders", () => { + const attributes = { outer: "%inner%", inner: "value" }; + const result = processModifierValue("%outer%", attributes); + expect(result).toBe("%inner%"); // Should not recursively process + }); + }); + }); + + + + describe("processModifierName", () => { + describe("CREATE replacement", () => { + it("should replace CREATE with repeatingID when both are provided", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "row123", + repOrder: [""], + }; + const result = processModifierName("repeating_skills_CREATE_name", options); + expect(result).toBe("repeating_skills_row123_name"); + }); + + it("should replace -CREATE without doubling a leading hyphen in the row ID", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "-Ounn8umZgulvFN0kH0Q", + repOrder: [""], + }; + const result = processModifierName("repeating_inventory_-CREATE_itemname", options); + expect(result).toBe("repeating_inventory_-Ounn8umZgulvFN0kH0Q_itemname"); + expect(result).not.toMatch(/inventory_--/); + }); + + it("should replace -CREATE with row IDs that do not start with a hyphen", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "unique-rowid-1234", + repOrder: [""], + }; + const result = processModifierName("repeating_inventory_-CREATE_itemname", options); + expect(result).toBe("repeating_inventory_unique-rowid-1234_itemname"); + }); + + it("should not replace CREATE when repeatingID is not provided", () => { + const options: ProcessModifierNameOptions = { + repOrder: [""], + }; + const result = processModifierName("repeating_skills_CREATE_name", options); + expect(result).toBe("repeating_skills_CREATE_name"); + }); + + it("should not replace CREATE when repeatingID is empty", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "", + repOrder: [""], + }; + const result = processModifierName("repeating_skills_CREATE_name", options); + expect(result).toBe("repeating_skills_CREATE_name"); + }); + + it("should handle multiple CREATE occurrences", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "row456", + repOrder: [""], + }; + const result = processModifierName("CREATE_CREATE_name", options); + expect(result).toBe("row456_CREATE_name"); // Only replaces first occurrence + }); + + it("should handle names without CREATE", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "row789", + repOrder: [""], + }; + const result = processModifierName("regular_attribute_name", options); + expect(result).toBe("regular_attribute_name"); + }); + }); + + describe("row index replacement", () => { + it("should replace $0 with first item from repOrder", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["row1", "row2", "row3"], + }; + const result = processModifierName("repeating_skills_$0_name", options); + expect(result).toBe("repeating_skills_row1_name"); + }); + + it("should replace $1 with second item from repOrder", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["row1", "row2", "row3"], + }; + const result = processModifierName("repeating_skills_$1_name", options); + expect(result).toBe("repeating_skills_row2_name"); + }); + + it("should replace $2 with third item from repOrder", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["row1", "row2", "row3"], + }; + const result = processModifierName("repeating_skills_$2_name", options); + expect(result).toBe("repeating_skills_row3_name"); + }); + + it("should handle out of bounds index gracefully", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["row1", "row2"] + }; + const result = processModifierName("repeating_skills_$5_name", options); + expect(result).toBe("repeating_skills_$5_name"); + }); + + it("should not replace when repOrder is empty", () => { + const options: ProcessModifierNameOptions = { + repOrder: [], + }; + const result = processModifierName("repeating_skills_$0_name", options); + expect(result).toBe("repeating_skills_$0_name"); + }); + + it("should handle single item repOrder", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["onlyrow"], + }; + const result = processModifierName("repeating_skills_$0_name", options); + expect(result).toBe("repeating_skills_onlyrow_name"); + }); + + it("should handle names without row index patterns", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["row1", "row2", "row3"], + }; + const result = processModifierName("regular_attribute_name", options); + expect(result).toBe("regular_attribute_name"); + }); + }); + + describe("edge cases", () => { + it("should handle empty name", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "row123", + repOrder: ["row1", "row2"], + }; + const result = processModifierName("", options); + expect(result).toBe(""); + }); + + it("should handle name with only CREATE", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "replacement", + repOrder: [""], + }; + const result = processModifierName("CREATE", options); + expect(result).toBe("replacement"); + }); + + it("should handle name with only row index", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["first", "second"], + }; + const result = processModifierName("$1", options); + expect(result).toBe("second"); + }); + + it("should handle options with undefined values", () => { + const options: ProcessModifierNameOptions = { + repeatingID: undefined, + repOrder: [""], + }; + const result = processModifierName("repeating_CREATE_$0_name", options); + expect(result).toBe("repeating_CREATE_$0_name"); + }); + + it("should handle special characters in repeatingID", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "row-123_special!", + repOrder: [""], + }; + const result = processModifierName("repeating_CREATE_name", options); + expect(result).toBe("repeating_row-123_special!_name"); + }); + + it("should handle case sensitivity", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "replacement", + repOrder: [""], + }; + const result = processModifierName("repeating_create_name", options); + expect(result).toBe("repeating_create_name"); // Should not replace lowercase 'create' + }); + }); + }); + + describe("processModifications", () => { + const options = {} as OptionsRecord; + + it("should error and omit modifications when row index cannot be resolved", () => { + const errors: string[] = []; + const changes: Attribute[] = [ + { name: "repeating_inventory_$0_itemname", current: "First Item" }, + ]; + + const result = processModifications( + changes, + {}, + options, + { inventory: [] }, + errors, + "Test Character", + ); + + expect(result).toEqual([]); + expect(errors).toEqual([ + "Repeating row number 0 invalid for character Test Character and repeating section repeating_inventory.", + ]); + }); + + it("should resolve row index when rep order is available", () => { + const errors: string[] = []; + const changes: Attribute[] = [ + { name: "repeating_inventory_$0_itemname", current: "First Item" }, + ]; + + const result = processModifications( + changes, + {}, + options, + { inventory: ["-row1"] }, + errors, + "Test Character", + ); + + expect(result).toEqual([ + { name: "repeating_inventory_-row1_itemname", current: "First Item" }, + ]); + expect(errors).toEqual([]); + }); + + it("should omit current when only max is provided (|| max-only syntax)", () => { + const result = processModifications( + [{ name: "hp", max: "23" }], + { hp: "10", hp_max: "20" }, + options, + {}, + [], + "Test Character", + ); + + expect(result).toEqual([{ name: "hp", max: "23" }]); + }); + + it("should skip the literal string undefined as current", () => { + const result = processModifications( + [{ name: "hp", current: "undefined", max: "23" }], + {}, + options, + {}, + [], + "Test Character", + ); + + expect(result).toEqual([{ name: "hp", max: "23" }]); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/unit/observer.test.ts b/ChatSetAttr/src/__tests__/unit/observer.test.ts new file mode 100644 index 0000000000..6e89cb4b57 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/observer.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ObserverAttributeSnapshot, ObserverCallback } from "../../types"; +import { createObserverAttributeObject } from "../../modules/observerPayload"; +import { notifyObservers, registerObserver } from "../../modules/observer"; + +function makeObserverObj(current = "10", max = "20") { + return createObserverAttributeObject("char1", "hp", "computed", { current, max }); +}; + +function makePrev(current = "5", max = "10"): ObserverAttributeSnapshot { + return { + _id: "", + _type: "computed", + _characterid: "char1", + name: "hp", + current, + max, + }; +}; + +describe("observer", () => { + beforeEach(async () => { + vi.resetModules(); + }); + + describe("registerObserver", () => { + it("should add a callback for a new event", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); + const mockCallback: ObserverCallback = vi.fn(); + const obj = makeObserverObj(); + + reg("add", mockCallback); + notify("add", obj); + + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith(obj, undefined); + }); + + it("should add multiple callbacks for the same event", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); + const mockCallback1: ObserverCallback = vi.fn(); + const mockCallback2: ObserverCallback = vi.fn(); + const obj = makeObserverObj(); + const prev = makePrev(); + + reg("change", mockCallback1); + reg("change", mockCallback2); + notify("change", obj, prev); + + expect(mockCallback1).toHaveBeenCalledWith(obj, prev); + expect(mockCallback2).toHaveBeenCalledWith(obj, prev); + }); + + it("should add callbacks for different events", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); + const addCallback: ObserverCallback = vi.fn(); + const changeCallback: ObserverCallback = vi.fn(); + const destroyCallback: ObserverCallback = vi.fn(); + const obj = makeObserverObj(); + const prev = makePrev(); + + reg("add", addCallback); + reg("change", changeCallback); + reg("destroy", destroyCallback); + + notify("add", obj); + notify("change", obj, prev); + notify("destroy", obj); + + expect(addCallback).toHaveBeenCalledWith(obj, undefined); + expect(changeCallback).toHaveBeenCalledWith(obj, prev); + expect(destroyCallback).toHaveBeenCalledWith(obj, undefined); + }); + + it("should allow the same callback to be added multiple times", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); + const mockCallback: ObserverCallback = vi.fn(); + const obj = makeObserverObj(); + + reg("add", mockCallback); + reg("add", mockCallback); + notify("add", obj); + + expect(mockCallback).toHaveBeenCalledTimes(2); + }); + }); + + describe("notifyObservers", () => { + it("should call all callbacks for a given event", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); + const mockCallback1: ObserverCallback = vi.fn(); + const mockCallback2: ObserverCallback = vi.fn(); + const obj = makeObserverObj("100", "50"); + const prev = makePrev("50", "25"); + + reg("change", mockCallback1); + reg("change", mockCallback2); + notify("change", obj, prev); + + expect(mockCallback1).toHaveBeenCalledWith(obj, prev); + expect(mockCallback2).toHaveBeenCalledWith(obj, prev); + }); + + it("should handle notification when no observers exist for event", () => { + expect(() => { + notifyObservers("add", makeObserverObj()); + }).not.toThrow(); + }); + + it("should only notify observers for the specific event", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); + const addCallback: ObserverCallback = vi.fn(); + const changeCallback: ObserverCallback = vi.fn(); + const obj = makeObserverObj(); + + reg("add", addCallback); + reg("change", changeCallback); + notify("add", obj); + + expect(addCallback).toHaveBeenCalledWith(obj, undefined); + expect(changeCallback).not.toHaveBeenCalled(); + }); + + it("should pass observer objects that support get", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); + const mockCallback: ObserverCallback = vi.fn(); + const obj = makeObserverObj("25", "30"); + + reg("change", mockCallback); + notify("change", obj, makePrev("10", "20")); + + expect(mockCallback.mock.calls[0][0].get("current")).toBe("25"); + expect(mockCallback.mock.calls[0][0].get("name")).toBe("hp"); + }); + + it("should handle callback execution errors gracefully", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); + const errorCallback: ObserverCallback = vi.fn(() => { + throw new Error("Callback error"); + }); + const normalCallback: ObserverCallback = vi.fn(); + const obj = makeObserverObj(); + + reg("destroy", errorCallback); + reg("destroy", normalCallback); + + expect(() => { + notify("destroy", obj); + }).toThrow("Callback error"); + + expect(errorCallback).toHaveBeenCalled(); + }); + + it("should call callbacks in the order they were added", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); + const callOrder: number[] = []; + const obj = makeObserverObj(); + + reg("add", vi.fn(() => callOrder.push(1))); + reg("add", vi.fn(() => callOrder.push(2))); + reg("add", vi.fn(() => callOrder.push(3))); + notify("add", obj); + + expect(callOrder).toEqual([1, 2, 3]); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/observerPayload.test.ts b/ChatSetAttr/src/__tests__/unit/observerPayload.test.ts new file mode 100644 index 0000000000..c89eb85c60 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/observerPayload.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { resetAllObjects } from "../../__mocks__/apiObjects.mock"; +import { + captureDeletePriorState, + createObserverAttributeObject, + emptySnapshot, + isLegacySheet, + isNewAttributeOrUser, + mergeAttributeState, + resolveObserverAddObj, + resolveObserverDestroyObj, + resolveObserverKind, + resolveObserverObj, + toSnapshot, + tryFindLegacyAttribute, +} from "../../modules/observerPayload"; + +function createBeaconCharacter(properties: Record) { + const character = createObj("character", properties); + Object.assign(character, { sheetEnvironment: "beacon" }); + return character; +} + +describe("observerPayload", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetAllObjects(); + }); + + afterEach(() => { + resetAllObjects(); + }); + + describe("isLegacySheet", () => { + it("should treat missing sheetEnvironment as legacy", () => { + createObj("character", { _id: "char1", name: "Hero" }); + expect(isLegacySheet("char1")).toBe(true); + }); + + it("should treat explicit legacy sheetEnvironment as legacy", () => { + const character = createObj("character", { _id: "char1", name: "Hero" }); + Object.assign(character, { sheetEnvironment: "legacy" }); + expect(isLegacySheet("char1")).toBe(true); + }); + + it("should not treat beacon sheetEnvironment as legacy", () => { + createBeaconCharacter({ _id: "char1", name: "Hero" }); + expect(isLegacySheet("char1")).toBe(false); + }); + + it("should not treat missing characters as legacy", () => { + expect(isLegacySheet("missing-char")).toBe(false); + }); + }); + + describe("createObserverAttributeObject", () => { + it("should expose get, set, and toJSON", () => { + const obj = createObserverAttributeObject("char1", "hp", "computed", { current: "10", max: "20" }); + + expect(obj.get("name")).toBe("hp"); + expect(obj.get("current")).toBe("10"); + expect(obj.get("max")).toBe("20"); + expect(obj.get("_type")).toBe("computed"); + expect(obj.toJSON()).toEqual({ + _id: "", + _type: "computed", + _characterid: "char1", + name: "hp", + current: "10", + max: "20", + }); + }); + + it("should call setSheetItem for computed set(key, value)", async () => { + const setSheetItemSpy = vi.spyOn(global, "setSheetItem").mockResolvedValue(true); + + const obj = createObserverAttributeObject("char1", "hp", "computed", { current: "10", max: "20" }); + obj.set("current", "99"); + + await vi.waitFor(() => { + expect(setSheetItemSpy).toHaveBeenCalledWith( + "char1", + "hp", + "99", + "current", + { allowThrow: true, createAttr: true, withWorker: true }, + ); + expect(obj.get("current")).toBe("99"); + }); + setSheetItemSpy.mockRestore(); + }); + + it("should call setSheetItem for userAttribute set({ max })", async () => { + const setSheetItemSpy = vi.spyOn(global, "setSheetItem").mockResolvedValue(true); + + const obj = createObserverAttributeObject("char1", "notes", "userAttribute", { current: "a", max: "" }); + obj.set({ max: "5" }); + + await vi.waitFor(() => { + expect(setSheetItemSpy).toHaveBeenCalledWith( + "char1", + "user.notes", + "5", + "max", + { allowThrow: true, createAttr: true, withWorker: true }, + ); + expect(obj.get("max")).toBe("5"); + }); + setSheetItemSpy.mockRestore(); + }); + }); + + describe("mergeAttributeState", () => { + it("should merge hp and hp_max keys", () => { + const state = mergeAttributeState( + "char1", + "hp", + { char1: { hp: 8, hp_max: 18 } }, + { char1: { hp: 10, hp_max: 20 } }, + false, + ); + + expect(state).toEqual({ + current: "10", + max: "20", + priorCurrent: "8", + priorMax: "18", + }); + }); + + it("should use prior values for delete operations", () => { + const state = mergeAttributeState( + "char1", + "hp", + { char1: { hp: 10, hp_max: 20 } }, + { char1: { hp: undefined, hp_max: undefined } }, + true, + ); + + expect(state).toEqual({ + current: "10", + max: "20", + priorCurrent: "10", + priorMax: "20", + }); + }); + }); + + describe("resolveObserverKind", () => { + it("should return attribute for default-sandbox legacy characters", async () => { + createObj("character", { _id: "char1", name: "Hero" }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10" }); + + await expect(resolveObserverKind("char1", "hp")).resolves.toBe("attribute"); + }); + + it("should return computed when beacon value exists", async () => { + createBeaconCharacter({ _id: "char1", name: "Hero" }); + await setSheetItem("char1", "beacon_hp", "10", "current"); + + await expect(resolveObserverKind("char1", "beacon_hp")).resolves.toBe("computed"); + }); + + it("should return computed on beacon sheets even when a legacy attribute object exists", async () => { + createBeaconCharacter({ _id: "char1", name: "Hero" }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "ComputedLike", current: "10" }); + await setSheetItem("char1", "ComputedLike", "10", "current"); + + await expect(resolveObserverKind("char1", "ComputedLike")).resolves.toBe("computed"); + }); + }); + + describe("resolveObserverObj", () => { + it("should prefer live legacy attribute objects on default-sandbox characters", () => { + createObj("character", { _id: "char1", name: "Hero" }); + const legacy = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10", max: "20" }); + + const obj = resolveObserverObj("char1", "hp", "attribute", { + current: "10", + max: "20", + priorCurrent: "5", + priorMax: "15", + }); + + expect(obj).toBe(legacy); + }); + + it("should build synthetic computed object on beacon sheets even when legacy attribute exists", () => { + createBeaconCharacter({ _id: "char1", name: "Hero" }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "ComputedLike", current: "10", max: "20" }); + + const obj = resolveObserverObj("char1", "ComputedLike", "computed", { + current: "10", + max: "20", + priorCurrent: "5", + priorMax: "15", + }); + + expect(obj.toJSON()._type).toBe("computed"); + expect(obj.get("current")).toBe("10"); + expect(obj).not.toBe(findObjs({ _type: "attribute", _characterid: "char1", name: "ComputedLike" })[0]); + }); + + it("should build synthetic object when legacy attribute is missing", () => { + const obj = resolveObserverObj("char1", "hp", "computed", { + current: "10", + max: "20", + priorCurrent: "5", + priorMax: "15", + }); + + expect(obj.toJSON()._type).toBe("computed"); + expect(obj.get("current")).toBe("10"); + }); + }); + + describe("resolveObserverDestroyObj", () => { + it("should return the live legacy attribute before deletion on default-sandbox characters", () => { + createObj("character", { _id: "char1", name: "Hero" }); + const legacy = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10", max: "20" }); + + expect(resolveObserverDestroyObj("char1", "hp", "attribute")).toBe(legacy); + }); + + it("should return undefined for beacon sheets", () => { + createBeaconCharacter({ _id: "char1", name: "Hero" }); + + expect(resolveObserverDestroyObj("char1", "hp", "attribute")).toBeUndefined(); + }); + }); + + describe("resolveObserverAddObj", () => { + it("should return synthetic object with added values", () => { + const obj = resolveObserverAddObj("char1", "NewAttr", "userAttribute", { current: "42", max: "100" }); + + expect(obj.get("current")).toBe("42"); + expect(obj.get("max")).toBe("100"); + expect(obj.toJSON()._type).toBe("userAttribute"); + }); + + it("should return live legacy object when available on default-sandbox characters", () => { + createObj("character", { _id: "char1", name: "Hero" }); + const legacy = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "NewAttr", current: "42", max: "100" }); + + const obj = resolveObserverAddObj("char1", "NewAttr", "attribute", { current: "42", max: "100" }); + + expect(obj).toBe(legacy); + }); + }); + + describe("captureDeletePriorState", () => { + it("should read max from legacy attribute when priorValues omit hp_max", async () => { + createObj("character", { _id: "char1", name: "Hero" }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10", max: "20" }); + + const state = await captureDeletePriorState("char1", "hp", "attribute", { char1: { hp: 10 } }); + + expect(state.current).toBe("10"); + expect(state.max).toBe("20"); + }); + }); + + describe("isNewAttributeOrUser", () => { + it("should be false for computed attributes", () => { + expect(isNewAttributeOrUser("computed", { + current: "1", + max: "", + priorCurrent: "", + priorMax: "", + })).toBe(false); + }); + + it("should be true for new user attributes", () => { + expect(isNewAttributeOrUser("userAttribute", { + current: "42", + max: "", + priorCurrent: "", + priorMax: "", + })).toBe(true); + }); + }); + + describe("toSnapshot and emptySnapshot", () => { + it("should build plain prev snapshots", () => { + expect(toSnapshot("char1", "hp", "attribute", { current: "5", max: "10" }, "attr1")).toEqual({ + _id: "attr1", + _type: "attribute", + _characterid: "char1", + name: "hp", + current: "5", + max: "10", + }); + + expect(emptySnapshot("char1", "hp", "computed")).toEqual({ + _id: "", + _type: "computed", + _characterid: "char1", + name: "hp", + current: "", + max: "", + }); + }); + }); + + describe("tryFindLegacyAttribute", () => { + it("should find legacy attributes by name", () => { + createObj("character", { _id: "char1", name: "Hero" }); + const legacy = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10" }); + + expect(tryFindLegacyAttribute("char1", "hp")).toBe(legacy); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/permissions.test.ts b/ChatSetAttr/src/__tests__/unit/permissions.test.ts new file mode 100644 index 0000000000..149fe22ece --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/permissions.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { checkPermissionForTarget, checkPermissions, getPermissions } from "../../modules/permissions"; + +const mockGetObj = vi.fn(); +const mockPlayerIsGM = vi.fn(); + +global.getObj = mockGetObj; +global.playerIsGM = mockPlayerIsGM; + +describe("permissions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetObj.mockReturnValue(undefined); + mockPlayerIsGM.mockReturnValue(false); + global.state.ChatSetAttr = { + ...global.state.ChatSetAttr, + playersCanModify: false, + }; + }); + + describe("checkPermissions", () => { + describe("when player object is not found", () => { + it("should grant full access and return true when playerID is API", () => { + expect(checkPermissions("API")).toBe(true); + + expect(getPermissions()).toEqual({ + playerID: "API", + isGM: true, + canModify: true, + }); + expect(mockGetObj).toHaveBeenCalledWith("player", "API"); + expect(mockPlayerIsGM).not.toHaveBeenCalled(); + }); + + it("should return false (and not throw) when playerID is not API", () => { + expect(checkPermissions("missing-player-id")).toBe(false); + + expect(mockGetObj).toHaveBeenCalledWith("player", "missing-player-id"); + expect(mockPlayerIsGM).not.toHaveBeenCalled(); + }); + }); + + describe("when player object is found", () => { + it("should return true and grant modify access for a GM", () => { + mockGetObj.mockReturnValue({ id: "gm-1" }); + mockPlayerIsGM.mockReturnValue(true); + + expect(checkPermissions("gm-1")).toBe(true); + + expect(getPermissions()).toEqual({ + playerID: "gm-1", + isGM: true, + canModify: true, + }); + }); + + it("should return true but withhold modify access for a non-GM when playersCanModify is off", () => { + mockGetObj.mockReturnValue({ id: "player-1" }); + + expect(checkPermissions("player-1")).toBe(true); + + expect(getPermissions()).toEqual({ + playerID: "player-1", + isGM: false, + canModify: false, + }); + }); + + it("should grant modify access for a non-GM when playersCanModify is on", () => { + mockGetObj.mockReturnValue({ id: "player-1" }); + global.state.ChatSetAttr = { + ...global.state.ChatSetAttr, + playersCanModify: true, + }; + + expect(checkPermissions("player-1")).toBe(true); + + expect(getPermissions()).toEqual({ + playerID: "player-1", + isGM: false, + canModify: true, + }); + }); + }); + }); + + describe("checkPermissionForTarget", () => { + it("should return true for API before any lookups", () => { + expect(checkPermissionForTarget("API", "char-1")).toBe(true); + + expect(mockGetObj).not.toHaveBeenCalled(); + expect(mockPlayerIsGM).not.toHaveBeenCalled(); + }); + + it("should return false when the player object is not found", () => { + expect(checkPermissionForTarget("missing-player", "char-1")).toBe(false); + + expect(mockGetObj).toHaveBeenCalledWith("player", "missing-player"); + expect(mockPlayerIsGM).not.toHaveBeenCalled(); + }); + + it("should return true for a GM", () => { + mockGetObj.mockImplementation((type, id) => + type === "player" && id === "gm-1" ? { id: "gm-1" } : undefined, + ); + mockPlayerIsGM.mockReturnValue(true); + + expect(checkPermissionForTarget("gm-1", "char-1")).toBe(true); + + expect(mockGetObj).toHaveBeenCalledWith("player", "gm-1"); + expect(mockPlayerIsGM).toHaveBeenCalledWith("gm-1"); + expect(mockGetObj).not.toHaveBeenCalledWith("character", "char-1"); + }); + + it("should return true when playersCanModify is enabled", () => { + mockGetObj.mockImplementation((type, id) => + type === "player" && id === "player-1" ? { id: "player-1" } : undefined, + ); + global.state.ChatSetAttr = { + ...global.state.ChatSetAttr, + playersCanModify: true, + }; + + expect(checkPermissionForTarget("player-1", "char-1")).toBe(true); + + expect(mockGetObj).toHaveBeenCalledWith("player", "player-1"); + expect(mockPlayerIsGM).toHaveBeenCalledWith("player-1"); + expect(mockGetObj).not.toHaveBeenCalledWith("character", "char-1"); + }); + + it("should return false when the target character is not found", () => { + mockGetObj.mockImplementation((type, id) => + type === "player" && id === "player-1" ? { id: "player-1" } : undefined, + ); + + expect(checkPermissionForTarget("player-1", "char-missing")).toBe(false); + + expect(mockGetObj).toHaveBeenCalledWith("player", "player-1"); + expect(mockGetObj).toHaveBeenCalledWith("character", "char-missing"); + }); + + it("should return true when the player controls the target character", () => { + const character = { + id: "char-1", + get: vi.fn((key: string) => key === "controlledby" ? "player-1,other-player" : undefined), + }; + mockGetObj.mockImplementation((type, id) => { + if (type === "player" && id === "player-1") return { id: "player-1" }; + if (type === "character" && id === "char-1") return character; + return undefined; + }); + + expect(checkPermissionForTarget("player-1", "char-1")).toBe(true); + + expect(mockGetObj).toHaveBeenCalledWith("character", "char-1"); + expect(character.get).toHaveBeenCalledWith("controlledby"); + }); + + it("should return false when the player does not control the target character", () => { + const character = { + id: "char-1", + get: vi.fn((key: string) => key === "controlledby" ? "other-player,third-player" : undefined), + }; + mockGetObj.mockImplementation((type, id) => { + if (type === "player" && id === "player-1") return { id: "player-1" }; + if (type === "character" && id === "char-1") return character; + return undefined; + }); + + expect(checkPermissionForTarget("player-1", "char-1")).toBe(false); + + expect(mockGetObj).toHaveBeenCalledWith("character", "char-1"); + expect(character.get).toHaveBeenCalledWith("controlledby"); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/repeating.test.ts b/ChatSetAttr/src/__tests__/unit/repeating.test.ts new file mode 100644 index 0000000000..e9f68bfdef --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/repeating.test.ts @@ -0,0 +1,853 @@ +import { describe, it, expect, beforeEach, vi, type MockedFunction } from "vitest"; +import { + extractRepeatingParts, + combineRepeatingParts, + isRepeatingAttribute, + hasCreateIdentifier, + hasIndexIdentifier, + convertRepOrderToArray, + discoverRowIds, + mergeRepOrder, + getIDFromIndex, + getRepOrderForSection, + extractRepeatingAttributes, + getAllSectionNames, + getAllRepOrders, + processRepeatingAttributes, + parseRepeatingIdentifierToken, + isRepeatingRowIdToken, + resolveRowIdInRepOrder, + parseRepeatingRowDeleteTarget, + expandRepeatingRowDeletes, + type RepeatingParts +} from "../../modules/repeating"; +import type { Attribute } from "../../types"; + +describe("repeating", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("extractRepeatingParts", () => { + it("should extract parts from a valid repeating attribute", () => { + const result = extractRepeatingParts("repeating_weapons_-abc123_name"); + expect(result).toEqual({ + section: "weapons", + identifier: "-abc123", + field: "name" + }); + }); + + it("should extract parts with underscore in field name", () => { + const result = extractRepeatingParts("repeating_spells_-def456_spell_level"); + expect(result).toEqual({ + section: "spells", + identifier: "-def456", + field: "spell_level" + }); + }); + + it("should extract parts with CREATE identifier", () => { + const result = extractRepeatingParts("repeating_inventory_CREATE_item_name"); + expect(result).toEqual({ + section: "inventory", + identifier: "CREATE", + field: "item_name" + }); + }); + + it("should extract parts with index identifier", () => { + const result = extractRepeatingParts("repeating_attacks_$1_attack_name"); + expect(result).toEqual({ + section: "attacks", + identifier: "$1", + field: "attack_name" + }); + }); + + it("should return null for non-repeating attributes", () => { + const result = extractRepeatingParts("strength"); + expect(result).toBeNull(); + }); + + it("should return null for empty string", () => { + const result = extractRepeatingParts(""); + expect(result).toBeNull(); + }); + + it("should return null for empty parts", () => { + const result = extractRepeatingParts("repeating_"); + expect(result).toBeNull(); + }); + }); + + describe("combineRepeatingParts", () => { + it("should combine parts into a valid repeating attribute name", () => { + const parts: RepeatingParts = { + section: "weapons", + identifier: "-abc123", + field: "name" + }; + const result = combineRepeatingParts(parts); + expect(result).toBe("repeating_weapons_-abc123_name"); + }); + + it("should handle parts with underscores in field", () => { + const parts: RepeatingParts = { + section: "spells", + identifier: "-def456", + field: "spell_level" + }; + const result = combineRepeatingParts(parts); + expect(result).toBe("repeating_spells_-def456_spell_level"); + }); + + it("should handle CREATE identifier", () => { + const parts: RepeatingParts = { + section: "inventory", + identifier: "CREATE", + field: "item_name" + }; + const result = combineRepeatingParts(parts); + expect(result).toBe("repeating_inventory_CREATE_item_name"); + }); + + it("should error on empty parts", () => { + const parts: RepeatingParts = { + section: "", + identifier: "", + field: "" + }; + expect(() => combineRepeatingParts(parts)).toThrowError(); + }); + }); + + describe("isRepeatingAttribute", () => { + it("should return true for valid repeating attributes", () => { + expect(isRepeatingAttribute("repeating_weapons_-abc123_name")).toBe(true); + expect(isRepeatingAttribute("repeating_spells_CREATE_spell_name")).toBe(true); + expect(isRepeatingAttribute("repeating_attacks_$1_attack_bonus")).toBe(true); + }); + + it("should return false for non-repeating attributes", () => { + expect(isRepeatingAttribute("strength")).toBe(false); + expect(isRepeatingAttribute("dexterity")).toBe(false); + expect(isRepeatingAttribute("hp")).toBe(false); + }); + + it("should return false for malformed repeating attributes", () => { + expect(isRepeatingAttribute("repeating_only_two")).toBe(false); + expect(isRepeatingAttribute("repeating_")).toBe(false); + expect(isRepeatingAttribute("")).toBe(false); + }); + + it("should return true for attributes that start with repeating_ and have minimal structure", () => { + expect(isRepeatingAttribute("repeating_a_b_c")).toBe(true); + }); + }); + + describe("hasCreateIdentifier", () => { + it("should return true for CREATE identifier", () => { + expect(hasCreateIdentifier("repeating_weapons_CREATE_name")).toBe(true); + }); + + it("should return false for non-CREATE identifiers", () => { + expect(hasCreateIdentifier("repeating_weapons_-abc123_name")).toBe(false); + expect(hasCreateIdentifier("repeating_attacks_$1_bonus")).toBe(false); + expect(hasCreateIdentifier("repeating_spells_normal_id_name")).toBe(false); + }); + }); + + describe("hasIndexIdentifier", () => { + it("should return true for valid index identifiers", () => { + expect(hasIndexIdentifier("repeating_weapons_$1_name")).toBe(true); + expect(hasIndexIdentifier("repeating_spells_$10_spell_name")).toBe(true); + expect(hasIndexIdentifier("repeating_attacks_$999_bonus")).toBe(true); + }); + + it("should return false for non-index identifiers", () => { + expect(hasIndexIdentifier("repeating_weapons_-abc123_name")).toBe(false); + expect(hasIndexIdentifier("repeating_spells_CREATE_spell_name")).toBe(false); + expect(hasIndexIdentifier("repeating_attacks_normal_id_bonus")).toBe(false); + }); + + it("should return false for invalid index formats", () => { + expect(hasIndexIdentifier("repeating_weapons_$abc_name")).toBe(false); + expect(hasIndexIdentifier("repeating_spells_1_spell_name")).toBe(false); + expect(hasIndexIdentifier("repeating_attacks_$_bonus")).toBe(false); + expect(hasIndexIdentifier("repeating_test_$$1_field")).toBe(false); + }); + + it("should return false for non-repeating attributes", () => { + expect(hasIndexIdentifier("strength")).toBe(false); + expect(hasIndexIdentifier("$1")).toBe(false); + }); + + it("should handle leading zeros in index", () => { + expect(hasIndexIdentifier("repeating_test_$01_field")).toBe(true); + expect(hasIndexIdentifier("repeating_test_$001_field")).toBe(true); + }); + }); + + describe("convertRepOrderToArray", () => { + it("should convert comma-separated string to array", () => { + const result = convertRepOrderToArray("-abc123,-def456,-ghi789"); + expect(result).toEqual(["-abc123", "-def456", "-ghi789"]); + }); + + it("should handle spaces around commas", () => { + const result = convertRepOrderToArray("-abc123, -def456 , -ghi789"); + expect(result).toEqual(["-abc123", "-def456", "-ghi789"]); + }); + + it("should handle single item", () => { + const result = convertRepOrderToArray("-abc123"); + expect(result).toEqual(["-abc123"]); + }); + + it("should handle empty string", () => { + const result = convertRepOrderToArray(""); + expect(result).toEqual([]); + }); + + it("should handle string with only commas", () => { + const result = convertRepOrderToArray(",,"); + expect(result).toEqual([]); + }); + + it("should handle mixed spacing", () => { + const result = convertRepOrderToArray(" -abc123 , -def456, -ghi789 "); + expect(result).toEqual(["-abc123", "-def456", "-ghi789"]); + }); + }); + + describe("mergeRepOrder", () => { + it("should preserve stored order for discovered IDs", () => { + const stored = ["-def456", "-abc123"]; + const discovered = ["-abc123", "-def456", "-ghi789"]; + expect(mergeRepOrder(stored, discovered)).toEqual(["-def456", "-abc123", "-ghi789"]); + }); + + it("should fall back to discovered IDs when stored order is empty", () => { + expect(mergeRepOrder([], ["-abc123", "-def456"])).toEqual(["-abc123", "-def456"]); + }); + + it("should omit stored IDs that are not on the character", () => { + expect(mergeRepOrder(["-missing", "-abc123"], ["-abc123"])).toEqual(["-abc123"]); + }); + }); + + describe("discoverRowIds", () => { + it("should collect unique row IDs from repeating attributes", () => { + vi.mocked(global.findObjs).mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_inventory_-abc123_itemname" : undefined }, + { get: (key: string) => key === "name" ? "repeating_inventory_-def456_itemcount" : undefined }, + { get: (key: string) => key === "name" ? "repeating_inventory_-abc123_itemweight" : undefined }, + ] as Roll20Object[]); + + expect(discoverRowIds("char1", "inventory")).toEqual(["-abc123", "-def456"]); + }); + + it("should ignore index and CREATE identifiers", () => { + vi.mocked(global.findObjs).mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_weapons_$0_name" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_CREATE_name" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_name" : undefined }, + ] as Roll20Object[]); + + expect(discoverRowIds("char1", "weapons")).toEqual(["-abc123"]); + }); + }); + + describe("getIDFromIndex", () => { + const repOrder = ["-abc123", "-def456", "-ghi789"]; + + it("should return row ID for valid 0-based index identifiers", () => { + expect(getIDFromIndex("repeating_weapons_$0_name", repOrder)).toBe("-abc123"); + expect(getIDFromIndex("repeating_weapons_$1_name", repOrder)).toBe("-def456"); + expect(getIDFromIndex("repeating_weapons_$2_name", repOrder)).toBe("-ghi789"); + }); + + it("should return null for index out of range", () => { + expect(getIDFromIndex("repeating_weapons_$3_name", repOrder)).toBeNull(); + expect(getIDFromIndex("repeating_weapons_$-1_name", repOrder)).toBeNull(); + expect(getIDFromIndex("repeating_weapons_$999_name", repOrder)).toBeNull(); + }); + + it("should return null for non-index identifiers", () => { + expect(getIDFromIndex("repeating_weapons_CREATE_name", repOrder)).toBeNull(); + expect(getIDFromIndex("repeating_weapons_-abc123_name", repOrder)).toBeNull(); + }); + + it("should return null for non-repeating attributes", () => { + expect(getIDFromIndex("strength", repOrder)).toBeNull(); + }); + + it("should return null for invalid index format", () => { + expect(getIDFromIndex("repeating_weapons_$abc_name", repOrder)).toBeNull(); + expect(getIDFromIndex("repeating_weapons_$_name", repOrder)).toBeNull(); + }); + + it("should handle empty repOrder array", () => { + expect(getIDFromIndex("repeating_weapons_$0_name", [])).toBeNull(); + }); + + it("should handle leading zeros in index", () => { + expect(getIDFromIndex("repeating_weapons_$00_name", repOrder)).toBe("-abc123"); + expect(getIDFromIndex("repeating_weapons_$01_name", repOrder)).toBe("-def456"); + }); + }); + + describe("getRepOrderForSection", () => { + let mockGetAttribute: MockedFunction; + + beforeEach(() => { + mockGetAttribute = vi.fn(); + libSmartAttributes.getAttribute = mockGetAttribute; + }); + + it("should call libSmartAttributes.getAttribute with correct parameters", async () => { + mockGetAttribute.mockResolvedValue("-abc123,-def456"); + + await getRepOrderForSection("char123", "weapons"); + + expect(mockGetAttribute).toHaveBeenCalledWith("char123", "_reporder_repeating_weapons"); + }); + + it("should return the reporder value", async () => { + const mockRepOrder = "-abc123,-def456,-ghi789"; + mockGetAttribute.mockResolvedValue(mockRepOrder); + + const result = await getRepOrderForSection("char123", "weapons"); + + expect(result).toBe(mockRepOrder); + }); + + it("should return undefined when libSmartAttributes returns undefined", async () => { + mockGetAttribute.mockResolvedValue(undefined); + + const result = await getRepOrderForSection("char123", "weapons"); + + expect(result).toBeUndefined(); + }); + + it("should handle different section names", async () => { + mockGetAttribute.mockResolvedValue("-test123"); + + await getRepOrderForSection("char456", "spells"); + + expect(mockGetAttribute).toHaveBeenCalledWith("char456", "_reporder_repeating_spells"); + }); + }); + + describe("extractRepeatingAttributes", () => { + it("should filter only repeating attributes", () => { + const attributes: Attribute[] = [ + { name: "strength", current: "18" }, + { name: "repeating_weapons_-abc123_name", current: "Sword" }, + { name: "dexterity", current: "14" }, + { name: "repeating_spells_CREATE_spell_name", current: "Fireball" }, + { name: "hp", current: "50" } + ]; + + const result = extractRepeatingAttributes(attributes); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe("repeating_weapons_-abc123_name"); + expect(result[1].name).toBe("repeating_spells_CREATE_spell_name"); + }); + + it("should return empty array when no repeating attributes", () => { + const attributes: Attribute[] = [ + { name: "strength", current: "18" }, + { name: "dexterity", current: "14" }, + { name: "hp", current: "50" } + ]; + + const result = extractRepeatingAttributes(attributes); + + expect(result).toEqual([]); + }); + + it("should handle attributes without names", () => { + const attributes: Attribute[] = [ + { name: "strength", current: "18" }, + { current: "14" }, // No name + { name: "repeating_weapons_-abc123_name", current: "Sword" } + ]; + + const result = extractRepeatingAttributes(attributes); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("repeating_weapons_-abc123_name"); + }); + + it("should handle empty array", () => { + const result = extractRepeatingAttributes([]); + expect(result).toEqual([]); + }); + }); + + describe("getAllSectionNames", () => { + it("should extract unique section names from repeating attributes", () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_-abc123_name", current: "Sword" }, + { name: "repeating_weapons_-def456_damage", current: "1d8" }, + { name: "repeating_spells_CREATE_spell_name", current: "Fireball" }, + { name: "repeating_inventory_$1_item", current: "Potion" }, + { name: "repeating_spells_-ghi789_level", current: "3" } + ]; + + const result = getAllSectionNames(attributes); + + expect(result).toHaveLength(3); + expect(result).toContain("weapons"); + expect(result).toContain("spells"); + expect(result).toContain("inventory"); + expect(result.sort()).toEqual(["inventory", "spells", "weapons"]); + }); + + it("should return empty array for no repeating attributes", () => { + const attributes: Attribute[] = [ + { name: "strength", current: "18" }, + { name: "dexterity", current: "14" } + ]; + + const result = getAllSectionNames(attributes); + + expect(result).toEqual([]); + }); + + it("should handle attributes without names", () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_-abc123_name", current: "Sword" }, + { current: "14" }, // No name + { name: "repeating_spells_CREATE_spell_name", current: "Fireball" } + ]; + + const result = getAllSectionNames(attributes); + + expect(result).toHaveLength(2); + expect(result).toContain("weapons"); + expect(result).toContain("spells"); + }); + + it("should handle empty array", () => { + const result = getAllSectionNames([]); + expect(result).toEqual([]); + }); + + it("should handle malformed repeating attributes gracefully", () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_-abc123_name", current: "Sword" }, + { name: "repeating_invalid", current: "bad" }, + { name: "repeating_spells_CREATE_spell_name", current: "Fireball" } + ]; + + const result = getAllSectionNames(attributes); + + expect(result).toHaveLength(2); + expect(result).toContain("weapons"); + expect(result).not.toContain("invalid"); // "repeating_invalid" has section "invalid" + expect(result).toContain("spells"); + }); + }); + + describe("getAllRepOrders", () => { + let mockGetAttribute: MockedFunction; + + beforeEach(() => { + mockGetAttribute = vi.fn(); + libSmartAttributes.getAttribute = mockGetAttribute; + }); + + it("should get reporders for all sections", async () => { + mockGetAttribute + .mockResolvedValueOnce("-abc123,-def456") // weapons + .mockResolvedValueOnce("-ghi789,-jkl101"); // spells + vi.mocked(global.findObjs).mockImplementation((props: Record) => { + if (props._characterid !== "char123") return []; + return [ + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_name" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-def456_damage" : undefined }, + { get: (key: string) => key === "name" ? "repeating_spells_-ghi789_level" : undefined }, + { get: (key: string) => key === "name" ? "repeating_spells_-jkl101_level" : undefined }, + ] as Roll20Object[]; + }); + + const result = await getAllRepOrders("char123", ["weapons", "spells"]); + + expect(mockGetAttribute).toHaveBeenCalledWith("char123", "_reporder_repeating_weapons"); + expect(mockGetAttribute).toHaveBeenCalledWith("char123", "_reporder_repeating_spells"); + expect(result).toEqual({ + weapons: ["-abc123", "-def456"], + spells: ["-ghi789", "-jkl101"] + }); + }); + + it("should handle sections with no reporder", async () => { + mockGetAttribute + .mockResolvedValueOnce("-abc123,-def456") // weapons + .mockResolvedValueOnce(undefined); // spells - no reporder + vi.mocked(global.findObjs).mockImplementation((props: Record) => { + if (props._characterid !== "char123") return []; + return [ + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_name" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-def456_damage" : undefined }, + ] as Roll20Object[]; + }); + + const result = await getAllRepOrders("char123", ["weapons", "spells"]); + + expect(result).toEqual({ + weapons: ["-abc123", "-def456"], + spells: [] + }); + }); + + it("should discover row IDs when _reporder_ is missing", async () => { + mockGetAttribute.mockResolvedValue(undefined); + vi.mocked(global.findObjs).mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_inventory_-row1_itemname" : undefined }, + ] as Roll20Object[]); + + const result = await getAllRepOrders("char123", ["inventory"]); + + expect(result).toEqual({ + inventory: ["-row1"], + }); + }); + + it("should handle empty section names array", async () => { + const result = await getAllRepOrders("char123", []); + + expect(result).toEqual({}); + expect(mockGetAttribute).not.toHaveBeenCalled(); + }); + + it("should handle single section", async () => { + mockGetAttribute.mockResolvedValue("-abc123"); + vi.mocked(global.findObjs).mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_name" : undefined }, + ] as Roll20Object[]); + + const result = await getAllRepOrders("char123", ["weapons"]); + + expect(result).toEqual({ + weapons: ["-abc123"] + }); + }); + }); + + describe("processRepeatingAttributes", () => { + let mockGetAttribute: MockedFunction; + + const mockWeaponRowDiscovery = () => { + vi.mocked(global.findObjs).mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_name" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-def456_damage" : undefined }, + ] as Roll20Object[]); + }; + + beforeEach(() => { + mockGetAttribute = vi.fn(); + libSmartAttributes.getAttribute = mockGetAttribute; + vi.stubGlobal("libUUID", { + generateRowID: vi.fn().mockReturnValue("-new123") + }); + }); + + it("should process normal repeating attributes unchanged", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_-abc123_name", current: "Sword" }, + { name: "strength", current: "18" } // Non-repeating + ]; + + mockGetAttribute.mockResolvedValue("-abc123,-def456"); + + const result = await processRepeatingAttributes("char123", attributes); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: "repeating_weapons_-abc123_name", + current: "Sword" + }); + }); + + it("should process CREATE identifiers by generating new IDs", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_CREATE_name", current: "New Sword" } + ]; + + mockGetAttribute.mockResolvedValue("-abc123,-def456"); + + const result = await processRepeatingAttributes("char123", attributes); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: "repeating_weapons_-new123_name", + current: "New Sword" + }); + expect(libUUID.generateRowID).toHaveBeenCalled(); + }); + + it("should process index identifiers correctly", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_$0_name", current: "First Weapon" }, + { name: "repeating_weapons_$1_damage", current: "1d8" } + ]; + + mockGetAttribute.mockResolvedValue("-abc123,-def456"); + mockWeaponRowDiscovery(); + + const result = await processRepeatingAttributes("char123", attributes); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + name: "repeating_weapons_-abc123_name", + current: "First Weapon" + }); + expect(result[1]).toEqual({ + name: "repeating_weapons_-def456_damage", + current: "1d8" + }); + }); + + it("should skip attributes with invalid index identifiers", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_$0_name", current: "First Weapon" }, + { name: "repeating_weapons_$5_name", current: "Invalid Index" } + ]; + + mockGetAttribute.mockResolvedValue("-abc123,-def456"); + mockWeaponRowDiscovery(); + + const result = await processRepeatingAttributes("char123", attributes); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: "repeating_weapons_-abc123_name", + current: "First Weapon" + }); + }); + + it("should handle mixed attribute types", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_-abc123_name", current: "Existing Sword" }, + { name: "repeating_weapons_CREATE_name", current: "New Sword" }, + { name: "repeating_weapons_$0_damage", current: "1d8" }, + { name: "repeating_spells_CREATE_spell", current: "New Spell" } + ]; + + mockGetAttribute + .mockResolvedValueOnce("-abc123,-def456") // weapons + .mockResolvedValueOnce("-ghi789"); // spells + vi.mocked(global.findObjs) + .mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_name" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-def456_damage" : undefined }, + ] as Roll20Object[]) + .mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_spells_-ghi789_level" : undefined }, + ] as Roll20Object[]); + + const result = await processRepeatingAttributes("char123", attributes); + + expect(result).toHaveLength(4); + expect(result[0]).toEqual({ + name: "repeating_weapons_-abc123_name", + current: "Existing Sword" + }); + expect(result[1]).toEqual({ + name: "repeating_weapons_-new123_name", + current: "New Sword" + }); + expect(result[2]).toEqual({ + name: "repeating_weapons_-abc123_damage", + current: "1d8" + }); + expect(result[3]).toEqual({ + name: "repeating_spells_-new123_spell", + current: "New Spell" + }); + }); + + it("should handle attributes without names", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_CREATE_name", current: "New Sword" }, + { current: "No name" } // No name property + ]; + + mockGetAttribute.mockResolvedValue("-abc123"); + + const result = await processRepeatingAttributes("char123", attributes); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: "repeating_weapons_-new123_name", + current: "New Sword" + }); + }); + + it("should handle malformed repeating attributes", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_CREATE_name", current: "Valid" }, + { name: "repeating_invalid", current: "Invalid" } // Malformed but valid structure + ]; + + mockGetAttribute.mockResolvedValue("-abc123"); + + const result = await processRepeatingAttributes("char123", attributes); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: "repeating_weapons_-new123_name", + current: "Valid" + }); + }); + + it("should handle empty attributes array", async () => { + const result = await processRepeatingAttributes("char123", []); + + expect(result).toEqual([]); + expect(mockGetAttribute).not.toHaveBeenCalled(); + }); + }); + + describe("parseRepeatingIdentifierToken", () => { + it("should classify index tokens", () => { + expect(parseRepeatingIdentifierToken("$0")).toEqual({ kind: "index", index: 0 }); + expect(parseRepeatingIdentifierToken("$12")).toEqual({ kind: "index", index: 12 }); + }); + + it("should classify CREATE tokens case-insensitively", () => { + expect(parseRepeatingIdentifierToken("CREATE")).toEqual({ kind: "create" }); + expect(parseRepeatingIdentifierToken("create")).toEqual({ kind: "create" }); + }); + + it("should classify libUUID-style row ID tokens", () => { + expect(parseRepeatingIdentifierToken("-abc123")).toEqual({ kind: "rowId", rowId: "-abc123" }); + expect(parseRepeatingIdentifierToken("0ABCxyzZ")).toEqual({ kind: "rowId", rowId: "0ABCxyzZ" }); + }); + + it("should not treat libUUID tokens as CREATE", () => { + expect(parseRepeatingIdentifierToken("-CREATE123longtoken")).toEqual({ + kind: "rowId", + rowId: "-CREATE123longtoken", + }); + }); + }); + + describe("isRepeatingRowIdToken", () => { + it("should return true for row ID tokens only", () => { + expect(isRepeatingRowIdToken("-abc123")).toBe(true); + expect(isRepeatingRowIdToken("0ABCxyzZ")).toBe(true); + expect(isRepeatingRowIdToken("$0")).toBe(false); + expect(isRepeatingRowIdToken("CREATE")).toBe(false); + }); + }); + + describe("resolveRowIdInRepOrder", () => { + const repOrder = ["-abc123", "-Def456", "0GHIxyzZ"]; + + it("should resolve row IDs case-insensitively and return canonical casing", () => { + expect(resolveRowIdInRepOrder(repOrder, "-ABC123")).toBe("-abc123"); + expect(resolveRowIdInRepOrder(repOrder, "-def456")).toBe("-Def456"); + expect(resolveRowIdInRepOrder(repOrder, "0ghixyzz")).toBe("0GHIxyzZ"); + }); + + it("should return null for unknown row IDs", () => { + expect(resolveRowIdInRepOrder(repOrder, "-missing")).toBeNull(); + }); + }); + + describe("parseRepeatingRowDeleteTarget", () => { + it("should parse row-only index targets", () => { + expect(parseRepeatingRowDeleteTarget("repeating_weapons_$0")).toEqual({ + sectionPrefix: "repeating_weapons", + rowIndex: 0, + }); + }); + + it("should parse row-only row ID targets", () => { + expect(parseRepeatingRowDeleteTarget("repeating_weapons_-def456")).toEqual({ + sectionPrefix: "repeating_weapons", + rowId: "-def456", + }); + }); + + it("should reject field-specific targets", () => { + expect(parseRepeatingRowDeleteTarget("repeating_weapons_$1_weaponname")).toBeNull(); + expect(parseRepeatingRowDeleteTarget("repeating_weapons_-abc123_name")).toBeNull(); + }); + + it("should reject CREATE row-only targets", () => { + expect(parseRepeatingRowDeleteTarget("repeating_weapons_CREATE")).toBeNull(); + }); + }); + + describe("expandRepeatingRowDeletes", () => { + it("should expand a row-only target into field deletes", () => { + vi.mocked(global.findObjs).mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_weaponname" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_damage" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-def456_weaponname" : undefined }, + ] as Roll20Object[]); + + const errors: string[] = []; + const result = expandRepeatingRowDeletes( + "char1", + [{ name: "repeating_weapons_$0" }], + { weapons: ["-abc123", "-def456"] }, + errors, + "Test Character", + ); + + expect(errors).toEqual([]); + expect(result).toEqual([ + { name: "repeating_weapons_-abc123_weaponname" }, + { name: "repeating_weapons_-abc123_damage" }, + ]); + }); + + it("should pass through non-row-delete targets unchanged", () => { + const errors: string[] = []; + const result = expandRepeatingRowDeletes( + "char1", + [{ name: "repeating_weapons_$1_weaponname" }], + { weapons: ["-abc123"] }, + errors, + "Test Character", + ); + + expect(result).toEqual([{ name: "repeating_weapons_$1_weaponname" }]); + }); + + it("should error on invalid row index", () => { + const errors: string[] = []; + const result = expandRepeatingRowDeletes( + "char1", + [{ name: "repeating_weapons_$5" }], + { weapons: ["-abc123"] }, + errors, + "Test Character", + ); + + expect(result).toEqual([]); + expect(errors).toEqual([ + "Repeating row number 5 invalid for character Test Character and repeating section repeating_weapons.", + ]); + }); + }); + + describe("getAllSectionNames row-delete targets", () => { + it("should extract section from row-only delete targets", () => { + const result = getAllSectionNames([ + { name: "repeating_weapons_$0" }, + { name: "repeating_inventory_-abc123" }, + ]); + + expect(result.sort()).toEqual(["inventory", "weapons"]); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/targets.test.ts b/ChatSetAttr/src/__tests__/unit/targets.test.ts new file mode 100644 index 0000000000..aa6e1c06f1 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/targets.test.ts @@ -0,0 +1,521 @@ +import { beforeEach, it, expect, describe, vi } from "vitest"; +import { generateTargets } from "../../modules/targets"; +import { checkPermissionForTarget, getPermissions } from "../../modules/permissions"; +import { getConfig } from "../../modules/config"; + +const makeMockMessage = ( + content: string = "", + selected: string[] = [] +): Roll20ChatMessage => { + return { + who: "testPlayer", + content, + selected: selected.map(id => ({ _id: id })), + } as Roll20ChatMessage; +}; + +const makeMockCharacter = ( + id: string, + controlledBy: string | null = null, + inParty: boolean = false +): Roll20Character => { + return { + id, + get: (prop: string) => { + if (prop === "controlledby") return controlledBy || ""; + if (prop === "inParty") return inParty; + return ""; + }, + } as Roll20Character; +}; + +const makeMockToken = ( + id: string, + represents: string | null = null +): Roll20Graphic => { + return { + id, + get: (prop: string) => { + if (prop === "represents") return represents || ""; + if (prop === "_subtype") return "token"; + return ""; + }, + } as Roll20Graphic; +}; + +vi.mock("../../modules/permissions", () => { + return { + getPermissions: vi.fn(), + checkPermissionForTarget: vi.fn(), + }; +}); + +vi.mock("../../modules/config", () => { + return { + getConfig: vi.fn(), + }; +}); + +describe("generateTargets", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("target = all", () => { + it("should report an error if the user is not a GM", () => { + // arrange + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: false }); + const message = makeMockMessage("", []); + + // act + const result = generateTargets(message, ["all"]); + + // assert + expect(result.targets).toEqual([]); + expect(result.errors).toContain("Only GMs can use the 'all' target option."); + }); + + it("should return all character IDs for 'all' target", () => { + // arrange + const message = makeMockMessage("", []); + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", null); + const characterThree = makeMockCharacter("char3", "player2"); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: true, canModify: true }); + vi.mocked(global.findObjs).mockReturnValueOnce([ + characterOne, + characterTwo, + characterThree, + ] as Roll20Character[]); + + // act + const result = generateTargets(message, ["all"]); + + // assert + expect(result.targets).toEqual(["char1", "char2", "char3"]); + }); + }); + + describe("target = allgm", () => { + it("should report an error if the user is not a GM", () => { + // arrange + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: false }); + const message = makeMockMessage("", []); + + // act + const result = generateTargets(message, ["allgm"]); + + // assert + expect(result.targets).toEqual([]); + expect(result.errors).toContain("Only GMs can use the 'allgm' target option."); + }); + + it("should return all GM character IDs for 'allgm' target", () => { + // arrange + const message = makeMockMessage("", []); + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", null); + const characterThree = makeMockCharacter("char3", "player2"); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: true, canModify: true }); + vi.mocked(global.findObjs).mockReturnValueOnce([ + characterOne, + characterTwo, + characterThree, + ] as Roll20Character[]); + + // act + const { targets } = generateTargets(message, ["allgm"]); + + // assert + expect(targets).toEqual(["char2"]); + }); + }); + + describe("target = allplayers", () => { + it("should report an error if the user is not a GM", () => { + // arrange + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: false }); + const message = makeMockMessage("", []); + + // act + const result = generateTargets(message, ["allplayers"]); + + // assert + expect(result.targets).toEqual([]); + expect(result.errors).toContain("Only GMs can use the 'allplayers' target option."); + }); + + it("should return all player character IDs for 'allplayers' target", () => { + // arrange + const message = makeMockMessage("", []); + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", null); + const characterThree = makeMockCharacter("char3", "player2"); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: true, canModify: true }); + vi.mocked(global.findObjs).mockReturnValueOnce([ + characterOne, + characterTwo, + characterThree, + ] as Roll20Character[]); + + // act + const result = generateTargets(message, ["allplayers"]); + + // assert + expect(result.targets).toEqual(["char1", "char3"]); + }); + }); + + describe("target = sel", () => { + it("should return character IDs based on selected tokens", () => { + // arrange + const characterOne = makeMockCharacter("char1"); + const characterTwo = makeMockCharacter("char2"); + const tokenOne = makeMockToken("token1", "char1"); + const tokenTwo = makeMockToken("token2", "char2"); + const message = makeMockMessage("", ["token1", "token2"]); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockImplementation((type: string, id: string) => { + if (type === "graphic") { + if (id === "token1") return tokenOne; + if (id === "token2") return tokenTwo; + } + if (type === "character") { + if (id === "char1") return characterOne; + if (id === "char2") return characterTwo; + } + return null; + }); + + // act + const result = generateTargets(message, ["sel"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + }); + + it("should handle missing tokens gracefully", () => { + // arrange + const message = makeMockMessage("", ["token1", "token2"]); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockReturnValue(null); + + // act + const result = generateTargets(message, ["sel"]); + + // assert + expect(result.targets).toEqual([]); + expect(result.errors).toContain("Selected token with ID token1 not found."); + expect(result.errors).toContain("Selected token with ID token2 not found."); + }); + + it("should handle tokens that don't represent characters", () => { + // arrange + const tokenOne = makeMockToken("token1", ""); + const message = makeMockMessage("", ["token1"]); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockImplementation((type: string, id: string) => { + if (type === "graphic" && id === "token1") return tokenOne; + return null; + }); + + // act + const result = generateTargets(message, ["sel"]); + + // assert + expect(result.targets).toEqual([]); + expect(result.errors).toContain("Token with ID token1 does not represent a character."); + }); + }); + + describe("target = charid", () => { + it("should return character IDs if the player has permission", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", "player1"); + const characterThree = makeMockCharacter("char3", "player2"); + const message = makeMockMessage("", []); + vi.mocked(checkPermissionForTarget).mockReturnValue(true); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockImplementation((type: string, id: string) => { + if (type === "character") { + if (id === "char1") return characterOne; + if (id === "char2") return characterTwo; + if (id === "char3") return characterThree; + } + return null; + }); + + // act + const result = generateTargets(message, ["charid char1,char2"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + }); + + it("should report an error for character IDs without permission", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", "player1"); + const characterThree = makeMockCharacter("char3", "player2"); + const message = makeMockMessage("", []); + vi.mocked(checkPermissionForTarget).mockImplementation((playerID: string, target: string) => { + return target !== "char3"; + }); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockImplementation((type: string, id: string) => { + if (type === "character") { + if (id === "char1") return characterOne; + if (id === "char2") return characterTwo; + if (id === "char3") return characterThree; + } + return null; + }); + + // act + const result = generateTargets(message, ["charid char1,char3,char2"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + expect(result.errors).toContain("Permission error. You do not have permission to modify character with ID char3."); + }); + }); + + describe("target = name", () => { + it("should return character IDs based on names if the player has permission", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", "player1"); + const characterThree = makeMockCharacter("char3", "player2"); + const message = makeMockMessage("", []); + vi.mocked(checkPermissionForTarget).mockReturnValue(true); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.findObjs).mockImplementation((props: Record) => { + const name = props.name as string; + if (name === "Alice") return [characterOne]; + if (name === "Bob") return [characterTwo]; + if (name === "Charlie") return [characterThree]; + return []; + }); + + // act + const result = generateTargets(message, ["name Alice,Bob"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + }); + + it("should report an error for names without permission", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", "player1"); + const characterThree = makeMockCharacter("char3", "player2"); + const message = makeMockMessage("", []); + vi.mocked(checkPermissionForTarget).mockImplementation((playerID: string, target: string) => { + return target !== "char3"; + }); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.findObjs).mockImplementation((props: Record) => { + const name = props.name as string; + if (name === "Alice") return [characterOne]; + if (name === "Bob") return [characterTwo]; + if (name === "Charlie") return [characterThree]; + return []; + }); + + // act + const result = generateTargets(message, ["name Alice,Charlie,Bob"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + expect(result.errors).toContain("Permission error. You do not have permission to modify character with name \"Charlie\"."); + }); + + it("should resolve a single multi-word character name", () => { + const characterBob = makeMockCharacter("char1", "player1"); + const message = makeMockMessage("", []); + const findObjsSpy = vi.fn((props: Record) => { + if (props.name === "bob the slayer") return [characterBob]; + return []; + }); + vi.mocked(checkPermissionForTarget).mockReturnValue(true); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.findObjs).mockImplementation(findObjsSpy); + + const result = generateTargets(message, ["name bob the slayer"]); + + expect(result.targets).toEqual(["char1"]); + expect(findObjsSpy).toHaveBeenCalledTimes(1); + expect(findObjsSpy).toHaveBeenCalledWith( + { _type: "character", name: "bob the slayer" }, + { caseInsensitive: true }, + ); + }); + + it("should resolve comma-separated multi-word character names", () => { + const characterBob = makeMockCharacter("char1", "player1"); + const characterTimmy = makeMockCharacter("char2", "player1"); + const message = makeMockMessage("", []); + const findObjsSpy = vi.fn((props: Record) => { + if (props.name === "bob the slayer") return [characterBob]; + if (props.name === "timmy the weak") return [characterTimmy]; + return []; + }); + vi.mocked(checkPermissionForTarget).mockReturnValue(true); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.findObjs).mockImplementation(findObjsSpy); + + const result = generateTargets(message, ["name bob the slayer, timmy the weak"]); + + expect(result.targets).toEqual(["char1", "char2"]); + expect(findObjsSpy).toHaveBeenCalledTimes(2); + expect(findObjsSpy).toHaveBeenCalledWith( + { _type: "character", name: "bob the slayer" }, + { caseInsensitive: true }, + ); + expect(findObjsSpy).toHaveBeenCalledWith( + { _type: "character", name: "timmy the weak" }, + { caseInsensitive: true }, + ); + }); + + it("should match character names case-insensitively", () => { + const characterBob = makeMockCharacter("char1", "player1"); + const message = makeMockMessage("", []); + vi.mocked(checkPermissionForTarget).mockReturnValue(true); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.findObjs).mockImplementation((props: Record, options?: { caseInsensitive?: boolean }) => { + if (props._type === "character" && options?.caseInsensitive && typeof props.name === "string") { + if (props.name.toLowerCase() === "bob the slayer") return [characterBob]; + } + return []; + }); + + const result = generateTargets(message, ["name bob the slayer"]); + + expect(result.targets).toEqual(["char1"]); + }); + }); + + describe("target = party", () => { + it("should return all party character IDs when GM", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1", true); + const characterTwo = makeMockCharacter("char2", null, true); + const message = makeMockMessage("", []); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: true, canModify: true }); + vi.mocked(getConfig).mockReturnValue({ playersCanTargetParty: false } as ReturnType); + vi.mocked(global.findObjs).mockReturnValueOnce([ + characterOne, + characterTwo, + ] as Roll20Character[]); + + // act + const result = generateTargets(message, ["party"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + expect(result.errors).toEqual([]); + }); + + it("should report an error if player cannot target party", () => { + // arrange + const message = makeMockMessage("", []); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(getConfig).mockReturnValue({ playersCanTargetParty: false } as ReturnType); + + // act + const result = generateTargets(message, ["party"]); + + // assert + expect(result.targets).toEqual([]); + expect(result.errors).toContain("Only GMs can use the 'party' target option."); + }); + + it("should return party character IDs when player is allowed", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1", true); + const characterTwo = makeMockCharacter("char2", null, true); + const message = makeMockMessage("", []); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(getConfig).mockReturnValue({ playersCanTargetParty: true } as ReturnType); + vi.mocked(global.findObjs).mockReturnValueOnce([ + characterOne, + characterTwo, + ] as Roll20Character[]); + + // act + const result = generateTargets(message, ["party"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + expect(result.errors).toEqual([]); + }); + }); + + describe("target = sel-party", () => { + it("should return only party characters from selected tokens", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1", true); + const characterTwo = makeMockCharacter("char2", "player1", false); + const characterThree = makeMockCharacter("char3", "player1", true); + const tokenOne = makeMockToken("token1", "char1"); + const tokenTwo = makeMockToken("token2", "char2"); + const tokenThree = makeMockToken("token3", "char3"); + const message = makeMockMessage("", ["token1", "token2", "token3"]); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockImplementation((type: string, id: string) => { + if (type === "graphic") { + if (id === "token1") return tokenOne; + if (id === "token2") return tokenTwo; + if (id === "token3") return tokenThree; + } + if (type === "character") { + if (id === "char1") return characterOne; + if (id === "char2") return characterTwo; + if (id === "char3") return characterThree; + } + return null; + }); + + // act + const result = generateTargets(message, ["sel-party"]); + + // assert + expect(result.targets).toEqual(["char1", "char3"]); + }); + }); + + describe("target = sel-noparty", () => { + it("should return only non-party characters from selected tokens", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1", true); + const characterTwo = makeMockCharacter("char2", "player1", false); + const characterThree = makeMockCharacter("char3", "player1", true); + const tokenOne = makeMockToken("token1", "char1"); + const tokenTwo = makeMockToken("token2", "char2"); + const tokenThree = makeMockToken("token3", "char3"); + const message = makeMockMessage("", ["token1", "token2", "token3"]); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockImplementation((type: string, id: string) => { + if (type === "graphic") { + if (id === "token1") return tokenOne; + if (id === "token2") return tokenTwo; + if (id === "token3") return tokenThree; + } + if (type === "character") { + if (id === "char1") return characterOne; + if (id === "char2") return characterTwo; + if (id === "char3") return characterThree; + } + return null; + }); + + // act + const result = generateTargets(message, ["sel-noparty"]); + + // assert + expect(result.targets).toEqual(["char2"]); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/unit/timer.test.ts b/ChatSetAttr/src/__tests__/unit/timer.test.ts new file mode 100644 index 0000000000..098824fafe --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/timer.test.ts @@ -0,0 +1,356 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { startTimer, clearTimer, clearAllTimers } from "../../modules/timer"; + +describe("timer", () => { + beforeEach(() => { + // Mock timers before each test + vi.useFakeTimers(); + }); + + afterEach(() => { + // Clean up timers after each test + vi.clearAllTimers(); + vi.useRealTimers(); + clearAllTimers(); + }); + + describe("startTimer", () => { + it("should execute callback after specified duration", () => { + const callback = vi.fn(); + const duration = 1000; + + startTimer("test-key", duration, callback); + + // Callback should not be called immediately + expect(callback).not.toHaveBeenCalled(); + + // Fast-forward time + vi.advanceTimersByTime(duration); + + // Callback should now be called + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should use default duration of 50ms when not specified", () => { + const callback = vi.fn(); + + startTimer("test-key", undefined, callback); + + // Advance by default duration (50ms) + vi.advanceTimersByTime(50); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should clear existing timer when starting new timer with same key", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const duration = 1000; + + // Start first timer + startTimer("same-key", duration, callback1); + + // Advance time partially + vi.advanceTimersByTime(duration / 2); + + // Start second timer with same key + startTimer("same-key", duration, callback2); + + // Advance time to complete the original duration + vi.advanceTimersByTime(duration / 2); + + // First callback should not be called (timer was cleared) + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + + // Advance time to complete the second timer + vi.advanceTimersByTime(duration / 2); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it("should handle multiple timers with different keys", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const callback3 = vi.fn(); + + startTimer("key1", 100, callback1); + startTimer("key2", 200, callback2); + startTimer("key3", 300, callback3); + + // Advance to first timer completion + vi.advanceTimersByTime(100); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + + // Advance to second timer completion + vi.advanceTimersByTime(100); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).not.toHaveBeenCalled(); + + // Advance to third timer completion + vi.advanceTimersByTime(100); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).toHaveBeenCalledTimes(1); + }); + + it("should remove timer from map after execution", () => { + const callback = vi.fn(); + + startTimer("cleanup-test", 100, callback); + + // Timer should be active + vi.advanceTimersByTime(50); + expect(callback).not.toHaveBeenCalled(); + + // Complete the timer + vi.advanceTimersByTime(50); + expect(callback).toHaveBeenCalledTimes(1); + + // Starting a new timer with same key should not interfere + const callback2 = vi.fn(); + startTimer("cleanup-test", 100, callback2); + vi.advanceTimersByTime(100); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it("should handle zero duration", () => { + const callback = vi.fn(); + + startTimer("zero-duration", 0, callback); + + // Should execute immediately on next tick + vi.advanceTimersByTime(0); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe("clearTimer", () => { + it("should prevent timer from executing when cleared", () => { + const callback = vi.fn(); + + startTimer("clear-test", 1000, callback); + + // Advance time partially + vi.advanceTimersByTime(500); + expect(callback).not.toHaveBeenCalled(); + + // Clear the timer + clearTimer("clear-test"); + + // Advance time past original completion + vi.advanceTimersByTime(1000); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should handle clearing non-existent timer gracefully", () => { + // This should not throw an error + expect(() => { + clearTimer("non-existent-key"); + }).not.toThrow(); + }); + + it("should only clear timer for specified key", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + startTimer("key1", 1000, callback1); + startTimer("key2", 1000, callback2); + + // Clear only one timer + clearTimer("key1"); + + // Advance time + vi.advanceTimersByTime(1000); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it("should allow clearing already completed timer", () => { + const callback = vi.fn(); + + startTimer("completed-test", 100, callback); + + // Complete the timer + vi.advanceTimersByTime(100); + expect(callback).toHaveBeenCalledTimes(1); + + // Clearing should not cause issues + expect(() => { + clearTimer("completed-test"); + }).not.toThrow(); + }); + }); + + describe("clearAllTimers", () => { + it("should clear all active timers", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const callback3 = vi.fn(); + + startTimer("key1", 1000, callback1); + startTimer("key2", 1500, callback2); + startTimer("key3", 2000, callback3); + + // Advance time partially + vi.advanceTimersByTime(500); + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + + // Clear all timers + clearAllTimers(); + + // Advance time past all original completion times + vi.advanceTimersByTime(2000); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + }); + + it("should handle clearing when no timers exist", () => { + // This should not throw an error + expect(() => { + clearAllTimers(); + }).not.toThrow(); + }); + + it("should allow starting new timers after clearing all", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + // Start and clear timers + startTimer("key1", 1000, callback1); + clearAllTimers(); + + // Start new timer + startTimer("key2", 500, callback2); + vi.advanceTimersByTime(500); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it("should clear timers even if some have already completed", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + startTimer("key1", 100, callback1); // Will complete + startTimer("key2", 1000, callback2); // Will be cleared + + // Complete first timer + vi.advanceTimersByTime(100); + expect(callback1).toHaveBeenCalledTimes(1); + + // Clear all timers (including the remaining active one) + clearAllTimers(); + + // Advance time + vi.advanceTimersByTime(1000); + expect(callback2).not.toHaveBeenCalled(); + }); + }); + + describe("edge cases and integration", () => { + it("should handle rapid timer creation and clearing", () => { + const callback = vi.fn(); + + // Rapidly create and clear timers + for (let i = 0; i < 10; i++) { + startTimer("rapid-test", 1000, callback); + if (i < 9) { + clearTimer("rapid-test"); + } + } + + // Only the last timer should remain + vi.advanceTimersByTime(1000); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should handle timer callback that throws an error", () => { + const errorCallback = vi.fn(() => { + throw new Error("Timer callback error"); + }); + const normalCallback = vi.fn(); + + startTimer("error-test", 100, errorCallback); + startTimer("normal-test", 200, normalCallback); + + // The error should not prevent other timers from working + expect(() => { + vi.advanceTimersByTime(100); + }).toThrow("Timer callback error"); + + // Normal timer should still work + vi.advanceTimersByTime(100); + expect(normalCallback).toHaveBeenCalledTimes(1); + }); + + it("should handle callback that starts another timer", () => { + const callback2 = vi.fn(); + const callback1 = vi.fn(() => { + startTimer("chained-timer", 100, callback2); + }); + + startTimer("initial-timer", 100, callback1); + + // Complete first timer + vi.advanceTimersByTime(100); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).not.toHaveBeenCalled(); + + // Complete chained timer + vi.advanceTimersByTime(100); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it("should handle very long durations", () => { + const callback = vi.fn(); + const longDuration = 1000000; // 1 million ms (about 16 minutes) + + startTimer("long-timer", longDuration, callback); + + // Advance by a large amount (but less than the duration) + vi.advanceTimersByTime(999999); + expect(callback).not.toHaveBeenCalled(); + + // Clear the timer + clearTimer("long-timer"); + + // Advance past the original duration to ensure it doesn't execute + vi.advanceTimersByTime(2); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should maintain timer isolation between different keys", () => { + const callbacks = Array.from({ length: 5 }, () => vi.fn()); + + // Start multiple timers with different keys and durations + callbacks.forEach((callback, index) => { + startTimer(`key-${index}`, (index + 1) * 100, callback); + }); + + // Clear one timer in the middle + clearTimer("key-2"); + + // Advance time to complete all timers + vi.advanceTimersByTime(500); + + // Check that only the cleared timer didn't execute + callbacks.forEach((callback, index) => { + if (index === 2) { + expect(callback).not.toHaveBeenCalled(); + } else { + expect(callback).toHaveBeenCalledTimes(1); + } + }); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/update.test.ts b/ChatSetAttr/src/__tests__/unit/update.test.ts new file mode 100644 index 0000000000..1e30fc51f4 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/update.test.ts @@ -0,0 +1,1195 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { AttributeRecord } from "../../types"; +import { resetAllObjects } from "../../__mocks__/apiObjects.mock"; +import { makeUpdate } from "../../modules/updates"; + +// Mock the config module +vi.mock("../../modules/config", () => ({ + getConfig: vi.fn(), +})); + +vi.mock("../../modules/observer", () => ({ + notifyObservers: vi.fn(), +})); + +// Mock libSmartAttributes global +const mocklibSmartAttributes = { + getAttribute: vi.fn(), + setAttribute: vi.fn(), + deleteAttribute: vi.fn(), +}; + +global.libSmartAttributes = mocklibSmartAttributes; + +import { getConfig } from "../../modules/config"; +import { notifyObservers } from "../../modules/observer"; +const mockGetConfig = vi.mocked(getConfig); +const mockNotifyObservers = vi.mocked(notifyObservers); + +describe("updates", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetAllObjects(); + mockGetConfig.mockReturnValue({ useWorkers: false }); + }); + + describe("Setting Attributes", () => { + it("should set regular current attributes", async () => { + const results: Record = { + "char1": { + "strength": 15, + "dexterity": 12, + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: false, setWithWorker: false } + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "dexterity", + 12, + "current", + { noCreate: false, setWithWorker: false } + ); + }); + + it("should set max attributes with _max suffix", async () => { + const results: Record = { + "char1": { + "hp_max": 25, + "mp_max": 15, + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "hp", + 25, + "max", + { noCreate: false, setWithWorker: false } + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "mp", + 15, + "max", + { noCreate: false, setWithWorker: false } + ); + }); + + it("should handle mixed current and max attributes", async () => { + const results: Record = { + "char1": { + "hp": 20, + "hp_max": 25, + "strength": 14, + "mp_max": 10, + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(4); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "hp", 20, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "hp", 25, "max", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "strength", 14, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "mp", 10, "max", expect.any(Object) + ); + }); + + it("should convert undefined values to empty strings", async () => { + const results: Record = { + "char1": { + "attribute1": undefined, + "attribute2_max": undefined, + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "attribute1", + "", + "current", + { noCreate: false, setWithWorker: false } + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "attribute2", + "", + "max", + { noCreate: false, setWithWorker: false } + ); + }); + + it("should handle different value types", async () => { + const results: Record = { + "char1": { + "name": "Gandalf", + "level": 10, + "active": true, + "bonus": 1.5, + "zero": 0, + "falsy": false, + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "name", "Gandalf", "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "level", 10, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "active", true, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "bonus", 1.5, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "zero", 0, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "falsy", false, "current", expect.any(Object) + ); + }); + + it("should handle multiple targets", async () => { + const results: Record = { + "char1": { "strength": 15 }, + "char2": { "dexterity": 12 }, + "char3": { "wisdom": 14 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(3); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "strength", 15, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char2", "dexterity", 12, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char3", "wisdom", 14, "current", expect.any(Object) + ); + }); + + it("should handle setAttribute errors", async () => { + const results: Record = { + "char1": { + "success": 15, + "failure": 12, + }, + }; + + mocklibSmartAttributes.setAttribute + .mockResolvedValueOnce(true) // success succeeds + .mockRejectedValueOnce(new Error("Failed to set failure")); // failure fails + + const result = await makeUpdate("setattr", results); + + expect(result.errors).toEqual([ + "Failed to set attribute 'failure' on target 'char1': Error: Failed to set failure", + ]); + }); + + it("should handle mixed success and failure across multiple attributes", async () => { + const results: Record = { + "char1": { + "success1": 10, + "failure1": 20, + "success2": 30, + "failure2": 40, + }, + }; + + mocklibSmartAttributes.setAttribute + .mockResolvedValueOnce(true) // success1 + .mockRejectedValueOnce(new Error("Error 1")) // failure1 + .mockResolvedValueOnce(true) // success2 + .mockRejectedValueOnce(new Error("Error 2")); // failure2 + + const result = await makeUpdate("setattr", results); + + expect(result.errors).toEqual([ + "Failed to set attribute 'failure1' on target 'char1': Error: Error 1", + "Failed to set attribute 'failure2' on target 'char1': Error: Error 2", + ]); + }); + + it("should handle non-Error thrown objects", async () => { + const results: Record = { + "char1": { + "attr1": "value1", + "attr2": "value2", + "attr3": "value3", + }, + }; + + mocklibSmartAttributes.setAttribute + .mockRejectedValueOnce("String error") + .mockRejectedValueOnce(null) + .mockRejectedValueOnce(undefined); + + const result = await makeUpdate("setattr", results); + + expect(result.errors).toEqual([ + "Failed to set attribute 'attr1' on target 'char1': String error", + "Failed to set attribute 'attr2' on target 'char1': null", + "Failed to set attribute 'attr3' on target 'char1': undefined", + ]); + }); + + it("should handle edge case attribute names", async () => { + const results: Record = { + "char1": { + "_max": "value", // attribute named exactly "_max" + "a_max": "value", // single character before _max + "": "empty_name", // empty string name + "max": "value", // attribute named "max" without underscore + "not_max_attribute": 10, // contains "max" but doesn't end with "_max" + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + const result = await makeUpdate("setattr", results); + + // "_max" should be treated as a max attribute with empty actualName + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "", "value", "max", expect.any(Object) + ); + // "a_max" should be treated as max attribute for "a" + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "a", "value", "max", expect.any(Object) + ); + // Empty string name should be current type + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "", "empty_name", "current", expect.any(Object) + ); + // "max" without underscore should be current type + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "max", "value", "current", expect.any(Object) + ); + // "not_max_attribute" should be current type + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "not_max_attribute", 10, "current", expect.any(Object) + ); + + expect(result.errors).toEqual([]); + }); + }); + + describe("other setting commands", () => { + it("should handle modattr the same as setattr", async () => { + const results: Record = { + "char1": { "strength": 15, "hp_max": 25 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("modattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "strength", 15, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "hp", 25, "max", expect.any(Object) + ); + }); + + it("should handle modbattr the same as setattr", async () => { + const results: Record = { + "char1": { "dexterity": 12, "mp_max": 15 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("modbattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "dexterity", 12, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "mp", 15, "max", expect.any(Object) + ); + }); + + it("should handle resetattr the same as setattr", async () => { + const results: Record = { + "char1": { "wisdom": 14, "sp_max": 20 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("resetattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "wisdom", 14, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "sp", 20, "max", expect.any(Object) + ); + }); + }); + + describe("delattr - comprehensive functionality tests", () => { + it("should delete regular attributes", async () => { + const results: Record = { + "char1": { + "oldAttribute": "someValue", // value should be ignored for delete + "temporaryAttr": 42, + }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + + await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( + "char1", + "oldAttribute", + "current" + ); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( + "char1", + "temporaryAttr", + "current" + ); + }); + + it("should handle multiple targets", async () => { + const results: Record = { + "char1": { "oldAttr1": "value" }, + "char2": { "oldAttr2": "value" }, + "char3": { "oldAttr3": "value" }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + + await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(3); + }); + + it("should handle deleteAttribute errors", async () => { + const results: Record = { + "char1": { + "attr1": "value", + "attr2": "value", + }, + }; + + mocklibSmartAttributes.deleteAttribute + .mockResolvedValueOnce(true) // attr1 succeeds + .mockRejectedValueOnce(new Error("Cannot delete attr2")); // attr2 fails + + const result = await makeUpdate("delattr", results); + + expect(result.errors).toEqual([ + "Failed to delete attribute 'attr2' on target 'char1': Error: Cannot delete attr2", + ]); + }); + }); + + describe("boolean return values and observers", () => { + it("should record error and failed key when setAttribute returns false", async () => { + mocklibSmartAttributes.setAttribute.mockResolvedValue(false); + + const result = await makeUpdate("setattr", { char1: { strength: 15 } }); + + expect(result.errors).toEqual([ + "Failed to set attribute 'strength' on target 'char1'.", + ]); + expect(result.failed).toEqual(["char1:strength"]); + expect(mockNotifyObservers).not.toHaveBeenCalled(); + }); + + it("should record error and failed key when deleteAttribute returns false", async () => { + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(false); + + const result = await makeUpdate("delattr", { char1: { strength: undefined } }); + + expect(result.errors).toEqual([ + "Failed to delete attribute 'strength' on target 'char1'.", + ]); + expect(result.failed).toEqual(["char1:strength"]); + expect(mockNotifyObservers).not.toHaveBeenCalled(); + }); + + it("should notify observers on successful set with change event", async () => { + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + const priorValues = { char1: { strength: 10 } }; + + await makeUpdate("setattr", { char1: { strength: 15 } }, { priorValues, operation: "setattr" }); + + expect(mockNotifyObservers).toHaveBeenCalledWith( + "change", + expect.objectContaining({ get: expect.any(Function) }), + expect.objectContaining({ + _characterid: "char1", + name: "strength", + current: "10", + }), + ); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("15"); + }); + + it("should notify observers with add and change when prior value is undefined", async () => { + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + vi.spyOn(global, "getSheetItem").mockImplementation(async (_charId, name) => { + if (name === "user.NewAttr") { + return "42"; + } + return undefined; + }); + const priorValues = { char1: {} }; + + await makeUpdate("setattr", { char1: { NewAttr: 42 } }, { priorValues, operation: "setattr" }); + + expect(mockNotifyObservers).toHaveBeenCalledTimes(2); + expect(mockNotifyObservers).toHaveBeenNthCalledWith( + 1, + "add", + expect.objectContaining({ get: expect.any(Function) }), + ); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("42"); + expect(mockNotifyObservers).toHaveBeenNthCalledWith( + 2, + "change", + expect.objectContaining({ get: expect.any(Function) }), + expect.objectContaining({ + name: "NewAttr", + current: "", + max: "", + }), + ); + expect(mockNotifyObservers.mock.calls[1][1].get("current")).toBe("42"); + }); + + it("should notify observers with destroy event on successful delete", async () => { + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + const priorValues = { char1: { strength: 10 } }; + + await makeUpdate("delattr", { char1: { strength: undefined } }, { priorValues }); + + expect(mockNotifyObservers).toHaveBeenCalledTimes(1); + expect(mockNotifyObservers.mock.calls[0][0]).toBe("destroy"); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("10"); + expect(mockNotifyObservers.mock.calls[0][1].get("name")).toBe("strength"); + }); + + it("should include max on destroy when legacy attribute had max", async () => { + createObj("character", { _id: "char1", name: "Hero" }); + const legacy = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10", max: "20" }); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + const priorValues = { char1: { hp: 10 } }; + + await makeUpdate("delattr", { char1: { hp: undefined } }, { priorValues }); + + expect(mockNotifyObservers).toHaveBeenCalledTimes(1); + expect(mockNotifyObservers.mock.calls[0][1]).toBe(legacy); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("10"); + expect(mockNotifyObservers.mock.calls[0][1].get("max")).toBe("20"); + }); + + it("should notify destroy for userAttribute delete without max", async () => { + const character = createObj("character", { _id: "char1", name: "Hero" }); + Object.assign(character, { sheetEnvironment: "beacon" }); + vi.spyOn(global, "getSheetItem").mockImplementation(async (_charId, name, type) => { + if (name === "user.UserOnlyAttr" && type === "current") { + return "42"; + } + return undefined; + }); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + const priorValues = { char1: { UserOnlyAttr: "42" } }; + + await makeUpdate("delattr", { + char1: { UserOnlyAttr: undefined, UserOnlyAttr_max: undefined }, + }, { priorValues }); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(1); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "UserOnlyAttr", "current"); + expect(mockNotifyObservers).toHaveBeenCalledTimes(1); + expect(mockNotifyObservers.mock.calls[0][0]).toBe("destroy"); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("42"); + expect(mockNotifyObservers.mock.calls[0][1].toJSON()._type).toBe("userAttribute"); + }); + + it("should notify destroy for userAttribute delete with max", async () => { + const character = createObj("character", { _id: "char1", name: "Hero" }); + Object.assign(character, { sheetEnvironment: "beacon" }); + vi.spyOn(global, "getSheetItem").mockImplementation(async (_charId, name, type) => { + if (name === "user.UserAttrWithMax") { + return type === "current" ? "42" : "100"; + } + return undefined; + }); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + const priorValues = { char1: { UserAttrWithMax: "42", UserAttrWithMax_max: "100" } }; + + await makeUpdate("delattr", { + char1: { UserAttrWithMax: undefined, UserAttrWithMax_max: undefined }, + }, { priorValues }); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(1); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "UserAttrWithMax", "current"); + expect(mockNotifyObservers).toHaveBeenCalledTimes(1); + expect(mockNotifyObservers.mock.calls[0][0]).toBe("destroy"); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("42"); + expect(mockNotifyObservers.mock.calls[0][1].get("max")).toBe("100"); + expect(mockNotifyObservers.mock.calls[0][1].toJSON()._type).toBe("userAttribute"); + }); + + it("should group hp and hp_max into one change notification", async () => { + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + const priorValues = { char1: { hp: 8, hp_max: 18 } }; + + await makeUpdate("setattr", { char1: { hp: 10, hp_max: 20 } }, { priorValues, operation: "setattr" }); + + expect(mockNotifyObservers).toHaveBeenCalledTimes(1); + expect(mockNotifyObservers).toHaveBeenCalledWith( + "change", + expect.objectContaining({ get: expect.any(Function) }), + expect.objectContaining({ + name: "hp", + current: "8", + max: "18", + }), + ); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("10"); + expect(mockNotifyObservers.mock.calls[0][1].get("max")).toBe("20"); + }); + + it("should not notify observers when setAttribute returns false", async () => { + mocklibSmartAttributes.setAttribute.mockResolvedValue(false); + const priorValues = { char1: { strength: 10 } }; + + await makeUpdate("setattr", { char1: { strength: 15 } }, { priorValues, operation: "setattr" }); + + expect(mockNotifyObservers).not.toHaveBeenCalled(); + }); + }); + + describe("options handling", () => { + it("should use default options when none provided", async () => { + const results: Record = { + "char1": { "strength": 15 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: false, setWithWorker: false } + ); + }); + + it("should use provided noCreate option", async () => { + const results: Record = { + "char1": { "strength": 15 }, + }; + const options = { noCreate: true }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results, options); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: true, setWithWorker: false } + ); + }); + + it("should use setWithWorker from config", async () => { + mockGetConfig.mockReturnValue({ useWorkers: true }); + + const results: Record = { + "char1": { "strength": 15 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: false, setWithWorker: true } + ); + }); + + it("should combine options and config", async () => { + mockGetConfig.mockReturnValue({ useWorkers: true }); + + const results: Record = { + "char1": { "strength": 15 }, + }; + const options = { noCreate: true }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results, options); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: true, setWithWorker: true } + ); + }); + }); + + describe("edge cases", () => { + it("should handle empty results", async () => { + const results: Record = {}; + + const result = await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).not.toHaveBeenCalled(); + expect(mocklibSmartAttributes.deleteAttribute).not.toHaveBeenCalled(); + expect(result.messages).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it("should handle targets with no attributes", async () => { + const results: Record = { + "char1": {}, + "char2": {}, + }; + + const result = await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).not.toHaveBeenCalled(); + expect(result.messages).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it("should handle mixed success and failure", async () => { + const results: Record = { + "char1": { + "success1": 10, + "failure1": 20, + "success2": 30, + "failure2": 40, + }, + }; + + mocklibSmartAttributes.setAttribute + .mockResolvedValueOnce(true) // success1 + .mockRejectedValueOnce(new Error("Error 1")) // failure1 + .mockResolvedValueOnce(true) // success2 + .mockRejectedValueOnce(new Error("Error 2")); // failure2 + + const result = await makeUpdate("setattr", results); + + expect(result.errors).toEqual([ + "Failed to set attribute 'failure1' on target 'char1': Error: Error 1", + "Failed to set attribute 'failure2' on target 'char1': Error: Error 2", + ]); + }); + + it("should handle non-Error thrown objects", async () => { + const results: Record = { + "char1": { "attr": "value" }, + }; + + mocklibSmartAttributes.setAttribute.mockRejectedValue("String error"); + + const result = await makeUpdate("setattr", results); + + expect(result.errors).toEqual([ + "Failed to set attribute 'attr' on target 'char1': String error", + ]); + }); + + it("should handle null and undefined thrown objects", async () => { + const results: Record = { + "char1": { + "attr1": "value1", + "attr2": "value2", + }, + }; + + mocklibSmartAttributes.setAttribute + .mockRejectedValueOnce(null) + .mockRejectedValueOnce(undefined); + + const result = await makeUpdate("setattr", results); + + expect(result.errors).toEqual([ + "Failed to set attribute 'attr1' on target 'char1': null", + "Failed to set attribute 'attr2' on target 'char1': undefined", + ]); + }); + }); + + describe("attribute name processing", () => { + it("should correctly identify and process _max attributes", async () => { + const results: Record = { + "char1": { + "hp": 20, + "hp_max": 25, + "strength_max": 18, + "not_max_attribute": 10, // contains "max" but doesn't end with "_max" + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenNthCalledWith(1, + "char1", "hp", 20, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenNthCalledWith(2, + "char1", "hp", 25, "max", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenNthCalledWith(3, + "char1", "strength", 18, "max", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenNthCalledWith(4, + "char1", "not_max_attribute", 10, "current", expect.any(Object) + ); + }); + + it("should handle edge case attribute names", async () => { + const results: Record = { + "char1": { + "_max": "value", // attribute named exactly "_max" + "a_max": "value", // single character before _max + "": "empty_name", // empty string name + "max": "value", // attribute named "max" without underscore + "not_max_attribute": 10, // contains "max" but doesn't end with "_max" + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results); + + // "_max" should be treated as a max attribute with empty actualName + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "", "value", "max", expect.any(Object) + ); + // "a_max" should be treated as max attribute for "a" + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "a", "value", "max", expect.any(Object) + ); + // Empty string name should be current type + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "", "empty_name", "current", expect.any(Object) + ); + // "max" without underscore should be current type + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "max", "value", "current", expect.any(Object) + ); + // "not_max_attribute" should be current type + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "not_max_attribute", 10, "current", expect.any(Object) + ); + + const result = await makeUpdate("setattr", results); + expect(result.errors).toEqual([]); + }); + }); + + describe("other setting commands - verification tests", () => { + it("should handle modattr the same as setattr", async () => { + const results: Record = { + "char1": { "strength": 15, "hp_max": 25 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("modattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "strength", 15, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "hp", 25, "max", expect.any(Object) + ); + }); + + it("should handle modbattr the same as setattr", async () => { + const results: Record = { + "char1": { "dexterity": 12, "mp_max": 15 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("modbattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "dexterity", 12, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "mp", 15, "max", expect.any(Object) + ); + }); + + it("should handle resetattr the same as setattr", async () => { + const results: Record = { + "char1": { "wisdom": 14, "sp_max": 20 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("resetattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "wisdom", 14, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "sp", 20, "max", expect.any(Object) + ); + }); + }); + + describe("delattr - comprehensive functionality tests", () => { + it("should delete regular attributes", async () => { + const results: Record = { + "char1": { + "strength": 15, + "dexterity": 12, + }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + + await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( + "char1", + "strength", + "current" + ); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( + "char1", + "dexterity", + "current" + ); + }); + + it("should delete max attributes with _max suffix", async () => { + const results: Record = { + "char1": { + "hp_max": 25, + "mp_max": 15, + }, + }; + const priorValues = { + char1: { + hp_max: 25, + mp_max: 15, + }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + + await makeUpdate("delattr", results, { priorValues }); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( + "char1", + "hp", + "max" + ); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( + "char1", + "mp", + "max" + ); + }); + + it("should handle multiple targets", async () => { + const results: Record = { + "char1": { "strength": 15 }, + "char2": { "dexterity": 12 }, + "char3": { "wisdom": 14 }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + + await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(3); + }); + + it("should handle deleteAttribute errors", async () => { + const results: Record = { + "char1": { + "strength": 15, + "dexterity": 12, + }, + }; + + mocklibSmartAttributes.deleteAttribute + .mockResolvedValueOnce(true) // strength succeeds + .mockRejectedValueOnce(new Error("Failed to delete dexterity")); // dexterity fails + + const result = await makeUpdate("delattr", results); + + expect(result.errors).toEqual([ + "Failed to delete attribute 'dexterity' on target 'char1': Error: Failed to delete dexterity", + ]); + }); + + it("should ignore attribute values for deletion", async () => { + const results: Record = { + "char1": { + "attr1": "any value", + "attr2": 123, + "attr3": "", + "attr4": undefined, + "attr5": true, + }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + + const result = await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(5); + // Values should be ignored - only character and attribute name matter + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr1", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr2", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr3", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr4", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr5", "current"); + + expect(result.errors).toEqual([]); + }); + + it("should handle mixed current and max attribute deletions", async () => { + const results: Record = { + "char1": { + "hp": 20, + "hp_max": 25, + "strength": 14, + "mp_max": 10, + }, + }; + const priorValues = { + char1: { + hp: 20, + hp_max: 25, + strength: 14, + mp_max: 10, + }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + + await makeUpdate("delattr", results, { priorValues }); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(3); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "hp", "current"); + expect(mocklibSmartAttributes.deleteAttribute).not.toHaveBeenCalledWith("char1", "hp", "max"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "strength", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "mp", "max"); + }); + + it("should handle delete errors for max-only attribute deletion", async () => { + const results: Record = { + "char1": { + "mp_max": 10, + }, + }; + const priorValues = { char1: { mp_max: 10 } }; + + mocklibSmartAttributes.deleteAttribute + .mockRejectedValueOnce(new Error("Max deletion failed")); + + const result = await makeUpdate("delattr", results, { priorValues }); + + expect(result.errors).toEqual([ + "Failed to delete attribute 'mp' on target 'char1': Error: Max deletion failed", + ]); + }); + + it("should handle edge case attribute names for deletion", async () => { + const results: Record = { + "char1": { + "_max": "value", // attribute named exactly "_max" + "a_max": "value", // single character before _max + "": "empty_name", // empty string name + "max": "value", // attribute named "max" without underscore + }, + }; + const priorValues = { + char1: { + a_max: "value", + }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + + const result = await makeUpdate("delattr", results, { priorValues }); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "a", "max"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "max", "current"); + + expect(result.errors).toEqual([]); + }); + + it("should handle non-Error thrown objects during deletion", async () => { + const results: Record = { + "char1": { + "attr1": "value1", + "attr2": "value2", + "attr3": "value3", + }, + }; + + mocklibSmartAttributes.deleteAttribute + .mockRejectedValueOnce("String error") + .mockRejectedValueOnce(null) + .mockRejectedValueOnce(undefined); + + const result = await makeUpdate("delattr", results); + + expect(result.errors).toEqual([ + "Failed to delete attribute 'attr1' on target 'char1': String error", + "Failed to delete attribute 'attr2' on target 'char1': null", + "Failed to delete attribute 'attr3' on target 'char1': undefined", + ]); + }); + + it("should handle large number of attributes for deletion", async () => { + const results: Record = { + "char1": Object.fromEntries( + Array.from({ length: 10 }, (_, i) => [`attr${i}`, `value${i}`]) + ), + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + + await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(10); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr0", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr9", "current"); + }); + + it("should handle deletion with empty results", async () => { + const results: Record = {}; + + const result = await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).not.toHaveBeenCalled(); + expect(result.messages).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it("should handle target with no attributes", async () => { + const results: Record = { + "char1": {}, + }; + + const result = await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).not.toHaveBeenCalled(); + expect(result.messages).toEqual([]); + expect(result.errors).toEqual([]); + }); + }); + + describe("configuration handling", () => { + it("should use setWithWorker from config", async () => { + mockGetConfig.mockReturnValue({ useWorkers: true }); + + const results: Record = { + "char1": { "strength": 15 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: false, setWithWorker: true } + ); + }); + + it("should handle undefined config", async () => { + mockGetConfig.mockReturnValue(undefined as unknown as ReturnType); + + const results: Record = { + "char1": { "strength": 15 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: false, setWithWorker: true } + ); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/versioning.test.ts b/ChatSetAttr/src/__tests__/unit/versioning.test.ts new file mode 100644 index 0000000000..c33718642d --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/versioning.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { checkForUpdates } from "../../modules/versioning"; +import { v2_0 } from "../../versions/2.0.0"; +import { setConfig } from "../../modules/config"; + +vi.mock("../../versions/2.0.0", () => { + return { + v2_0: { + appliesTo: "<=3", + version: 4, + update: vi.fn(), + }, + }; +}); + +const migration2 = vi.mocked(v2_0); + +vi.mock("../../modules/config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + setConfig: vi.fn(), + }; +}); + +describe("versioning", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(v2_0).appliesTo = "<=3"; + }); + + describe("checkForUpdates", () => { + it("should run migration when state schema is 3", () => { + checkForUpdates(3); + + expect(migration2.update).toHaveBeenCalled(); + expect(setConfig).toHaveBeenCalledWith({ version: 4 }); + }); + + it("should run migration when state schema is 0", () => { + checkForUpdates(0); + + expect(migration2.update).toHaveBeenCalled(); + expect(setConfig).toHaveBeenCalledWith({ version: 4 }); + }); + + it("should not run migration when state schema is already 4", () => { + checkForUpdates(4); + + expect(migration2.update).not.toHaveBeenCalled(); + expect(setConfig).not.toHaveBeenCalled(); + }); + + it("should not run migration when state schema is greater than 4", () => { + checkForUpdates(5); + + expect(migration2.update).not.toHaveBeenCalled(); + }); + + it("should call setConfig with schema version 4 after update", () => { + checkForUpdates(3); + + expect(setConfig).toHaveBeenCalledWith({ version: 4 }); + }); + }); + + describe("comparison operators", () => { + it("should handle < operator correctly", () => { + vi.mocked(v2_0).appliesTo = "<4"; + + checkForUpdates(3); + expect(migration2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates(4); + expect(migration2.update).not.toHaveBeenCalled(); + }); + + it("should handle >= operator correctly", () => { + vi.mocked(v2_0).appliesTo = ">=3"; + + checkForUpdates(3); + expect(migration2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates(2); + expect(migration2.update).not.toHaveBeenCalled(); + }); + + it("should handle > operator correctly", () => { + vi.mocked(v2_0).appliesTo = ">2"; + + checkForUpdates(3); + expect(migration2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates(2); + expect(migration2.update).not.toHaveBeenCalled(); + }); + + it("should handle = operator correctly", () => { + vi.mocked(v2_0).appliesTo = "=3"; + + checkForUpdates(3); + expect(migration2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates(4); + expect(migration2.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/utils/chat.test.ts b/ChatSetAttr/src/__tests__/utils/chat.test.ts new file mode 100644 index 0000000000..3e12ff5deb --- /dev/null +++ b/ChatSetAttr/src/__tests__/utils/chat.test.ts @@ -0,0 +1,528 @@ +/* eslint-disable @stylistic/quotes */ +import { describe, it, expect } from "vitest"; +import { h, rawHtml, s } from "../../utils/chat"; + +describe("chat utilities", () => { + describe("h function (JSX helper)", () => { + it("should create a simple HTML tag with no attributes or children", () => { + const result = h("div"); + expect(result.html).toBe("
    "); + }); + + it("should create a tag with text content", () => { + const result = h("p", {}, "Hello World"); + expect(result.html).toBe("

    Hello World

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

    Text with & < > characters

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

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

    Heading

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

    Hello 世界 Café ☕

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

    {msg}

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

    Message 1

    Message 2

    Message 3

    "); + }); + + it("should handle deeply nested arrays", () => { + const nestedArray = [["A", "B"], [["C", "D"], "E"]]; + const result = h("div", {}, ...nestedArray); + expect(result.html).toBe("
    ABCDE
    "); + }); + + it("should handle empty arrays", () => { + const result = h("div", {}, []); + expect(result.html).toBe("
    "); + }); + + it("should handle arrays containing empty strings", () => { + const children = ["Start", "", "End"]; + const result = h("div", {}, children); + expect(result.html).toBe("
    StartEnd
    "); + }); + }); + }); + + describe("rawHtml", () => { + it("should render SafeHtml children without escaping", () => { + const result = h("div", {}, rawHtml("

    Hello

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

    Hello

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

    Line 1

    Line 2

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

    Line 1

    Line 2

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

    Title

    Some content

    '); + }); + + it("should handle multiple styled elements", () => { + const buttonStyle = s({ backgroundColor: "blue", color: "white", padding: "8px 16px" }); + const linkStyle = s({ textDecoration: "none", color: "blue" }); + + const button = h("button", { style: buttonStyle, type: "submit" }, "Submit"); + const link = h("a", { style: linkStyle, href: "#" }, "Link"); + const result = h("div", {}, button, " ", link); + + expect(result.html).toBe('
    Link
    '); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/env.d.ts b/ChatSetAttr/src/env.d.ts new file mode 100644 index 0000000000..67ad026ba9 --- /dev/null +++ b/ChatSetAttr/src/env.d.ts @@ -0,0 +1,42 @@ +/// + +/** Module shim so `declare global` augments the shared global scope for real modules. */ +export {}; + +declare global { + /** Roll20 loads this from the libSmartAttributes dependency script (not bundled). */ + var libSmartAttributes: typeof import("lib-smart-attributes").default; + /** Roll20 loads this from the libUUID dependency script (not bundled). */ + var libUUID: { generateRowID(): string; generateUUID(): string }; + /** Roll20 Mod API persistent script state. */ + var state: { + ChatSetAttr?: Record & { version?: number | string; scriptVersion?: string }; + [key: string]: unknown; + }; + /** Roll20 One-Click script page configuration. */ + var globalconfig: { + chatsetattr?: Record & { lastsaved?: number }; + [key: string]: unknown; + }; + + function h( + tagName: string, + attributes: Record, + ...children: (string | import("./utils/chat").SafeHtml | null | undefined)[] + ): import("./utils/chat").SafeHtml; + var s: typeof import("./utils/chat").s; + + namespace JSX { + type Element = import("./utils/chat").SafeHtml; + interface IntrinsicElements { + [elemName: string]: { + [key: string]: string | undefined; + }; + } + } + + /** Present on Beacon-capable Mod API sandboxes; not yet in @roll20/api-types. */ + interface BeaconCampaignMarker { + computedSummary?: unknown; + } +} diff --git a/ChatSetAttr/src/index.ts b/ChatSetAttr/src/index.ts new file mode 100644 index 0000000000..3684519ae6 --- /dev/null +++ b/ChatSetAttr/src/index.ts @@ -0,0 +1,17 @@ +import { registerHandlers } from "./modules/main"; +import { checkGlobalConfig, persistStateVersionMetadata, syncScriptVersion } from "./modules/config"; +import { syncHelpHandoutOnStartup } from "./modules/help"; +import { update, welcome } from "./modules/versioning"; +import "./utils/chat"; + +on("ready", () => { + checkGlobalConfig(); + registerHandlers(); + syncHelpHandoutOnStartup(); + syncScriptVersion(); + update(); + welcome(); + persistStateVersionMetadata(); +}); + +export { registerObserver } from "./modules/observer"; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/attributes.ts b/ChatSetAttr/src/modules/attributes.ts new file mode 100644 index 0000000000..e84f64873c --- /dev/null +++ b/ChatSetAttr/src/modules/attributes.ts @@ -0,0 +1,103 @@ +import type { Attribute, AttributeRecord, AttributeValue } from "../types"; +import { buildSetAttributeOptions } from "./updates"; + +// #region Get Attributes +async function getSingleAttribute(target: string, attributeName: string): Promise { + const isMax = attributeName.endsWith("_max"); + const type = isMax ? "max" : "current"; + if (isMax) { + attributeName = attributeName.slice(0, -4); // remove '_max' + } + try { + const attribute = await libSmartAttributes.getAttribute(target, attributeName, type); + return attribute; + } catch { + return undefined; + } +}; + +export async function getAttributes( + target: string, + attributeNames: string[] | AttributeRecord, +): Promise { + const attributes: AttributeRecord = {}; + if (Array.isArray(attributeNames)) { + for (const name of attributeNames) { + const cleanName = name.replace(/[^a-zA-Z0-9_]/g, ""); + attributes[cleanName] = await getSingleAttribute(target, cleanName); + } + } else { + for (const name in attributeNames) { + const cleanName = name.replace(/[^a-zA-Z0-9_]/g, ""); + attributes[cleanName] = await getSingleAttribute(target, cleanName); + } + } + return attributes; +}; + +// #region Set Attributes +export async function setSingleAttribute( + target: string, + attributeName: string, + value: string | number | boolean, + options: Record, + isMax?: boolean +): Promise { + const type = isMax ? "max" : "current"; + const setOptions = buildSetAttributeOptions({ + noCreate: options.noCreate, + setWithWorker: options.setWithWorker, + }); + const ok = await libSmartAttributes.setAttribute(target, attributeName, value, type, setOptions); + if (!ok) { + throw new Error(`Failed to set attribute '${attributeName}' on target '${target}'.`); + } +}; + +export async function setAttributes( + target: string, + attributes: Attribute[], + options: Record +): Promise { + const promises: Promise[] = []; + for (const attr of attributes) { + if (attr.current === undefined && attr.max === undefined) { + throw new Error("Attribute must have at least a current or max value defined."); + } + if (attr.name === undefined) { + throw new Error("Attribute must have a name defined."); + } + if (attr.current !== undefined) { + const promise = setSingleAttribute(target, attr.name, attr.current, options); + promises.push(promise); + } + if (attr.max !== undefined) { + const isMax = true; + const promise = setSingleAttribute(target, attr.name, attr.max, options, isMax); + promises.push(promise); + } + } + await Promise.all(promises); +}; + +// #region Delete Attributes +export async function deleteSingleAttribute( + target: string, + attributeName: string, +): Promise { + const ok = await libSmartAttributes.deleteAttribute(target, attributeName); + if (!ok) { + throw new Error(`Failed to delete attribute '${attributeName}' on target '${target}'.`); + } +}; + +export async function deleteAttributes( + target: string, + attributeNames: string[], +): Promise { + const promises: Promise[] = []; + for (const name of attributeNames) { + promises.push(deleteSingleAttribute(target, name)); + } + await Promise.all(promises); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/beaconSupport.ts b/ChatSetAttr/src/modules/beaconSupport.ts new file mode 100644 index 0000000000..9fda4376ac --- /dev/null +++ b/ChatSetAttr/src/modules/beaconSupport.ts @@ -0,0 +1,8 @@ +export function isBeaconSupported(): boolean { + try { + const campaign = Campaign() as Roll20Campaign & BeaconCampaignMarker; + return !!campaign.computedSummary; + } catch { + return false; + } +} diff --git a/ChatSetAttr/src/modules/chat.ts b/ChatSetAttr/src/modules/chat.ts new file mode 100644 index 0000000000..6e56abfa51 --- /dev/null +++ b/ChatSetAttr/src/modules/chat.ts @@ -0,0 +1,121 @@ +import { createChatMessage, createErrorMessage } from "../templates/messages"; +import { createNoticeMessage } from "../templates/notice"; +import { createNotifyMessage } from "../templates/notification"; +import { createWelcomeMessage } from "../templates/welcome"; + +export const BEACON_UNSUPPORTED_NOTICE_TITLE = "Notice: Beacon Support Disabled"; + +export const BEACON_UNSUPPORTED_NOTICE_BODY = + "Beacon character sheets are not supported on this Mod API Sandbox. " + + "Please be sure you have the correct Sandbox selected on the Mod API Scripts Page " + + "and restart the Mod API Server."; + +export const LONG_RUNNING_QUERY_TITLE = "Long Running Query"; + +export const LONG_RUNNING_QUERY_BODY = + "The operation is taking a long time to execute. This may be due to a large number of " + + "targets or attributes being processed. Please be patient as the operation completes."; + +export type CommandOutputOptions = { + silent?: boolean; + mute?: boolean; +}; + +export type NormalizedCommandOutputOptions = { + silent: boolean; + mute: boolean; +}; + +export type FeedbackDeliveryOptions = { + from?: string; + public?: boolean; +}; + +export function getWhisperPrefix(playerID: string): string { + const player = getPlayerName(playerID); + return `/w "${player || "GM"}" `; +} + +export function normalizeCommandOutputOptions( + options: CommandOutputOptions = {}, +): NormalizedCommandOutputOptions { + return { + mute: Boolean(options.mute), + silent: Boolean(options.silent || options.mute), + }; +}; + +export function getPlayerName(playerID: string): string | undefined { + const player = getObj("player", playerID); + return player?.get("_displayname") || undefined; +}; + +export function sendMessages( + playerID: string, + header: string, + messages: string[], + delivery?: FeedbackDeliveryOptions, + output?: NormalizedCommandOutputOptions, +): void { + if (output?.silent) { + return; + } + + const from = delivery?.from ?? "ChatSetAttr"; + const newMessage = createChatMessage(header, messages); + const chatMessage = delivery?.public + ? newMessage + : `${getWhisperPrefix(playerID)}${newMessage}`; + sendChat(from, chatMessage); +}; + +export function sendErrors( + playerID: string, + header: string, + errors: string[], + from?: string, + output?: NormalizedCommandOutputOptions, +): void { + if (errors.length === 0 || output?.mute) { + return; + } + + const sender = from ?? "ChatSetAttr"; + const newMessage = createErrorMessage(header, errors); + sendChat(sender, `${getWhisperPrefix(playerID)}${newMessage}`); +}; + +export function sendDelayMessage( + playerID: string, + output?: NormalizedCommandOutputOptions, +): void { + if (output?.silent) { + return; + } + + const noticeMessage = createNoticeMessage(LONG_RUNNING_QUERY_TITLE, LONG_RUNNING_QUERY_BODY); + sendChat( + "ChatSetAttr", + `${getWhisperPrefix(playerID)}${noticeMessage}`, + undefined, + { noarchive: true }, + ); +}; + +export function sendBeaconUnsupportedNotice(): void { + const message = createNoticeMessage( + BEACON_UNSUPPORTED_NOTICE_TITLE, + BEACON_UNSUPPORTED_NOTICE_BODY, + ); + sendChat("ChatSetAttr", "/w gm " + message, undefined, { noarchive: true }); +}; + +export function sendNotification(title: string, content: string, archive?: boolean): void { + const notifyMessage = createNotifyMessage(title, content); + sendChat("ChatSetAttr", "/w gm " + notifyMessage, undefined, { noarchive: archive }); +}; + +export function sendWelcomeMessage(): void { + const welcomeMessage = createWelcomeMessage(); + sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); +}; diff --git a/ChatSetAttr/src/modules/commands.ts b/ChatSetAttr/src/modules/commands.ts new file mode 100644 index 0000000000..d2217fbb60 --- /dev/null +++ b/ChatSetAttr/src/modules/commands.ts @@ -0,0 +1,322 @@ +import type { Command, Attribute, AttributeRecord, AttributeValue, FeedbackObject } from "../types"; +import { getAttributes } from "./attributes"; +import { getCharName } from "./helpers"; + +export type HandlerResponse = { + result: AttributeRecord; + errors: string[]; +}; + +export type HandlerFunction = ( + changes: Attribute[], + target: string, + referenced: string[], + noCreate: boolean, + feedback: FeedbackObject, +) => Promise; + +// region Command Handlers +export async function setattr( + changes: Attribute[], + target: string, + referenced: string[] = [], + noCreate = false, + _feedback: FeedbackObject, +): Promise { + const result: AttributeRecord = {}; + const errors: string[] = []; + + const request = createRequestList(referenced, changes, false); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + + for (const change of changes) { + const { name, current, max } = change; + if (!name) continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Missing attribute ${name} not created for ${characterName}.`); + continue; + } + if (current !== undefined) { + result[name] = current; + } + if (max !== undefined) { + result[`${name}_max`] = max; + } + } + + return { + result, + errors, + }; +} + +export async function modattr( + changes: Attribute[], + target: string, + referenced: string[], + noCreate = false, + _feedback: FeedbackObject, +): Promise { + const result: AttributeRecord = {}; + const errors: string[] = []; + + const currentValues = await getCurrentValues(target, referenced, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + + for (const change of changes) { + const { name, current, max } = change; + if (!name) continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be modified.`); + continue; + } + const asNumber = Number(currentValues[name] ?? 0); + if (isNaN(asNumber)) { + errors.push(`Attribute '${name}' is not number-valued and so cannot be modified.`); + continue; + } + if (current !== undefined) { + result[name] = calculateModifiedValue(asNumber, current); + } + if (max !== undefined) { + result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); + } + } + + return { + result, + errors, + }; +} + +export async function modbattr( + changes: Attribute[], + target: string, + referenced: string[], + noCreate = false, + _feedback: FeedbackObject, +): Promise { + const result: AttributeRecord = {}; + const errors: string[] = []; + + const request = createRequestList(referenced, changes, true); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + + for (const change of changes) { + const { name, current, max } = change; + if (!name) continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be modified.`); + continue; + } + const asNumber = Number(currentValues[name]); + if (isNaN(asNumber)) { + errors.push(`Attribute '${name}' is not number-valued and so cannot be modified.`); + continue; + } + if (current !== undefined) { + result[name] = calculateModifiedValue(asNumber, current); + } + if (max !== undefined) { + result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); + } + const newMax = result[`${name}_max`] ?? currentValues[`${name}_max`]; + if (newMax !== undefined) { + const start = currentValues[name]; + result[name] = calculateBoundValue( + result[name] ?? start, + newMax, + ); + } + } + + return { + result, + errors, + }; +} + +export async function resetattr( + changes: Attribute[], + target: string, + referenced: string[], + noCreate = false, + _feedback: FeedbackObject, +): Promise { + const result: AttributeRecord = {}; + const errors: string[] = []; + + const request = createRequestList(referenced, changes, true); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + + for (const change of changes) { + const { name } = change; + if (!name) continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be reset.`); + continue; + } + const maxName = `${name}_max`; + if (currentValues[maxName] !== undefined) { + const maxAsNumber = Number(currentValues[maxName]); + if (isNaN(maxAsNumber)) { + errors.push(`Attribute '${maxName}' is not number-valued and so cannot be used to reset '${name}'.`); + continue; + } + result[name] = maxAsNumber; + } else { + result[name] = 0; + } + } + + return { + result, + errors, + }; +} + +export async function delattr( + changes: Attribute[], + target: string, + referenced: string[], + _: boolean, + _feedback: FeedbackObject, +): Promise { + const result: AttributeRecord = {}; + + for (const change of changes) { + const { name } = change; + if (!name) continue; + result[name] = undefined; + result[`${name}_max`] = undefined; + } + + return { + result, + errors: [], + }; +} + +export type HandlerDictionary = { + [key in Command]?: HandlerFunction; +}; + +export const handlers: HandlerDictionary = { + setattr, + modattr, + modbattr, + resetattr, + delattr, +}; + +// #region Helper Functions + +function createRequestList( + referenced: string[], + changes: Attribute[], + includeMax = true, +): string[] { + const requestSet = new Set([...referenced]); + for (const change of changes) { + if (change.name) { + requestSet.add(change.name); + if (includeMax) { + requestSet.add(`${change.name}_max`); + } + } + } + return Array.from(requestSet); +} + +function extractUndefinedAttributes( + attributes: AttributeRecord +): string[] { + const names: string[] = []; + for (const name in attributes) { + if (name.endsWith("_max")) continue; + if (attributes[name] === undefined) { + names.push(name); + } + } + return names; +} + +async function getCurrentValues( + target: string, + referenced: string[], + changes: Attribute[] +): Promise { + const queriedAttributes = new Set([...referenced]); + for (const change of changes) { + if (change.name) { + queriedAttributes.add(change.name); + queriedAttributes.add(`${change.name}_max`); + } + } + const attributes = await getAttributes(target, Array.from(queriedAttributes)); + return attributes; +} + +function calculateModifiedValue( + baseValue: string | number | boolean | undefined, + modification: string | number | boolean +): number { + const operator = getOperator(modification); + baseValue = Number(baseValue); + if (operator) { + modification = Number(String(modification).substring(1)); + } else { + modification = Number(modification); + } + if (isNaN(baseValue)) baseValue = 0; + if (isNaN(modification)) modification = 0; + return applyCalculation(baseValue, modification, operator); +} + +function getOperator(value: string | number | boolean) { + if (typeof value === "string") { + const match = value.match(/^([+\-*/])/); + if (match) { + return match[1]; + } + } + return; +} + +function applyCalculation( + baseValue: number, + modification: number, + operator: string = "+" +): number { + modification = Number(modification); + switch (operator) { + case "+": + return baseValue + modification; + case "-": + return baseValue - modification; + case "*": + return baseValue * modification; + case "/": + return modification !== 0 ? baseValue / modification : baseValue; + default: + return baseValue + modification; + } +} + +function calculateBoundValue( + currentValue: AttributeValue, + maxValue: AttributeValue +): number { + currentValue = Number(currentValue); + maxValue = Number(maxValue); + if (isNaN(currentValue)) currentValue = 0; + if (isNaN(maxValue)) return currentValue; + return Math.max(Math.min(currentValue, maxValue), 0); +} diff --git a/ChatSetAttr/src/modules/config.ts b/ChatSetAttr/src/modules/config.ts new file mode 100644 index 0000000000..7dadfe931a --- /dev/null +++ b/ChatSetAttr/src/modules/config.ts @@ -0,0 +1,225 @@ +import scriptJson from "../../script.json" assert { type: "json" }; +import { getWhisperPrefix, sendNotification } from "./chat"; +import { createConfigMessage } from "../templates/config"; + +export const STATE_SCHEMA_VERSION = 4; + +type ScriptConfig = { + version: number; + scriptVersion: string; + globalconfigCache: GlobalConfigCache; + playersCanTargetParty: boolean; + playersCanModify: boolean; + playersCanEvaluate: boolean; + useWorkers: boolean; + helpContentUpdatedAt: number; + flags: string[]; +}; + +type GlobalConfigCache = Record & { + lastsaved: number; +}; + +type GlobalConfigRecord = Record & { + lastsaved?: number; +}; + +export const GLOBAL_CONFIG_OPTIONS = [ + { + label: "Players can modify all characters", + key: "playersCanModify", + value: "playersCanModify", + }, + { + label: "Players can use --evaluate", + key: "playersCanEvaluate", + value: "playersCanEvaluate", + }, + { + label: "Trigger sheet workers when setting attributes", + key: "useWorkers", + value: "useWorkers", + }, + { + label: "Players can target party members", + key: "playersCanTargetParty", + value: "playersCanTargetParty", + }, +] as const; + +const DEFAULT_CONFIG: ScriptConfig = { + version: STATE_SCHEMA_VERSION, + scriptVersion: scriptJson.version, + globalconfigCache: { + lastsaved: 0, + }, + playersCanTargetParty: true, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + helpContentUpdatedAt: 0, + flags: [], +}; + +export function getStateSchemaVersion(raw: unknown): number { + if (raw === undefined || raw === null) { + return 0; + } + if (typeof raw === "number" && Number.isFinite(raw)) { + return raw; + } + if (typeof raw === "string") { + const parsed = Number(raw); + if (Number.isFinite(parsed) && /^\d+$/.test(raw.trim())) { + return parsed; + } + return 0; + } + return 0; +} + +function ensureChatSetAttrState(): Record { + if (!state.ChatSetAttr) { + state.ChatSetAttr = {}; + } + return state.ChatSetAttr; +} + +export function getPersistedSchemaVersion(): number { + return getStateSchemaVersion(state.ChatSetAttr?.version); +} + +export function persistStateVersionMetadata(): void { + const raw = ensureChatSetAttrState(); + const schemaVersion = getStateSchemaVersion(raw.version); + + if (schemaVersion > 0 && raw.version !== schemaVersion) { + raw.version = schemaVersion; + } + + if (!Object.hasOwn(raw, "scriptVersion") || raw.scriptVersion !== scriptJson.version) { + raw.scriptVersion = scriptJson.version; + } +} + +export function syncScriptVersion(): void { + persistStateVersionMetadata(); +} + +export function parseGlobalConfigCheckbox( + g: GlobalConfigRecord, + label: string, + valueField: string, +): boolean { + return g[label] === valueField; +}; + +function buildCacheSnapshot(g: GlobalConfigRecord): GlobalConfigCache { + const cache: GlobalConfigCache = { lastsaved: g.lastsaved ?? 0 }; + for (const option of GLOBAL_CONFIG_OPTIONS) { + cache[option.label] = `${g[option.label] ?? ""}`; + } + return cache; +}; + +export function checkGlobalConfig(): string[] { + const g = globalconfig?.chatsetattr as GlobalConfigRecord | undefined; + if (!g?.lastsaved) { + return []; + } + + state.ChatSetAttr = state.ChatSetAttr || {}; + const cache = (state.ChatSetAttr.globalconfigCache || { lastsaved: 0 }) as GlobalConfigCache; + if (g.lastsaved <= cache.lastsaved) { + return []; + } + + const changes: string[] = []; + for (const option of GLOBAL_CONFIG_OPTIONS) { + const newRaw = `${g[option.label] ?? ""}`; + const oldRaw = `${cache[option.label] ?? ""}`; + if (newRaw === oldRaw) { + continue; + } + + const newValue = parseGlobalConfigCheckbox(g, option.label, option.value); + const oldValue = getConfig()[option.key]; + if (newValue === oldValue) { + continue; + } + + state.ChatSetAttr[option.key] = newValue; + changes.push(`${option.key}: ${String(oldValue)} → ${String(newValue)}`); + } + + state.ChatSetAttr.globalconfigCache = buildCacheSnapshot(g); + + if (changes.length > 0) { + log(`ChatSetAttr: Imported Global Config settings: ${changes.join(", ")}`); + sendNotification( + "ChatSetAttr Global Config", + `

    New settings imported from Global Config:

      ${changes.map(change => `
    • ${change}
    • `).join("")}
    `, + false, + ); + } + + return changes; +}; + +export function getConfig() { + const stateConfig = state?.ChatSetAttr || {}; + return { + ...DEFAULT_CONFIG, + ...stateConfig, + }; +}; + +export function setConfig(newConfig: Record) { + Object.assign(ensureChatSetAttrState(), newConfig); +}; + +export function hasFlag(flag: string) { + const config = getConfig(); + return config.flags.includes(flag); +}; + +export function setFlag(flag: string) { + const config = getConfig(); + if (!hasFlag(flag)) { + config.flags.push(flag); + setConfig({ flags: config.flags }); + } +}; + +export function checkConfigMessage(message: string) { + return message.startsWith("!setattr-config"); +}; + +const FLAG_MAP: Record = { + "--players-can-modify": "playersCanModify", + "--players-can-evaluate": "playersCanEvaluate", + "--players-can-target-party": "playersCanTargetParty", + "--use-workers": "useWorkers", +} as const; + +export function handleConfigCommand(message: string, playerID: string) { + message = message.replace("!setattr-config", "").trim(); + const args = message.split(/\s+/); + const newConfig: Record = {}; + for (const arg of args) { + const cleanArg = arg.toLowerCase(); + const flag = FLAG_MAP[cleanArg]; + if (flag !== undefined) { + newConfig[flag] = !getConfig()[flag]; + log(`Toggled config option: ${flag} to ${newConfig[flag]}`); + } + } + setConfig(newConfig); + const configMessage = createConfigMessage(); + sendChat( + "ChatSetAttr", + `${getWhisperPrefix(playerID)}${configMessage}`, + undefined, + { noarchive: true }, + ); +}; diff --git a/ChatSetAttr/src/modules/feedback.ts b/ChatSetAttr/src/modules/feedback.ts new file mode 100644 index 0000000000..d99f6b5b55 --- /dev/null +++ b/ChatSetAttr/src/modules/feedback.ts @@ -0,0 +1,138 @@ +import type { Attribute, AttributeRecord, AttributeValue, FeedbackObject } from "../types"; + +function formatFeedbackValue(value: AttributeValue): string { + if (value === undefined || value === null || value === "") { + return "(empty)"; + } + return String(value); +} + +function formatAttributePart( + name: string, + result: AttributeRecord, +): string | null { + const hasCurrent = Object.hasOwn(result, name); + const maxKey = `${name}_max`; + const hasMax = Object.hasOwn(result, maxKey); + + if (!hasCurrent && !hasMax) { + return null; + } + + if (hasCurrent && hasMax) { + return `${name} to ${formatFeedbackValue(result[name])} / ${formatFeedbackValue(result[maxKey])}`; + } + if (hasCurrent) { + return `${name} to ${formatFeedbackValue(result[name])}`; + } + return `${name} to ${formatFeedbackValue(result[maxKey])} (max)`; +} + +export function formatSettingFeedback( + characterName: string, + changes: Attribute[], + result: AttributeRecord, +): string | null { + const parts: string[] = []; + + for (const change of changes) { + if (!change.name) continue; + const part = formatAttributePart(change.name, result); + if (part) { + parts.push(part); + } + } + + if (parts.length === 0) { + return null; + } + + return `Setting ${parts.join(", ")} for character ${characterName}.`; +} + +export function formatDeleteFeedback( + characterName: string, + changes: Attribute[], + result: AttributeRecord, +): string | null { + const names: string[] = []; + + for (const change of changes) { + if (!change.name) continue; + if (Object.hasOwn(result, change.name)) { + names.push(change.name); + } + } + + if (names.length === 0) { + return null; + } + + return `Deleting attribute(s) ${names.join(", ")} for character ${characterName}.`; +} + +export function createFeedbackMessage( + characterName: string, + feedback: FeedbackObject | undefined, + startingValues: AttributeRecord, + targetValues: AttributeRecord, +): string { + let message = feedback?.content ?? ""; + // _NAMEJ_: will insert the attribute name. + // _TCURJ_: will insert what you are changing the current value to (or changing by, if you're using --mod or --modb). + // _TMAXJ_: will insert what you are changing the maximum value to (or changing by, if you're using --mod or --modb). + // _CHARNAME_: will insert the character name. + // _CURJ_: will insert the final current value of the attribute, for this character. + // _MAXJ_: will insert the final maximum value of the attribute, for this character. + + const targetValueKeys = getChangedAttributeNames(targetValues); + message = message.replace("_CHARNAME_", characterName); + + message = message.replace(/_(NAME|TCUR|TMAX|CUR|MAX)(\d+)_/g, (_, key: string, num: string) => { + const index = parseInt(num, 10); + const attributeName = targetValueKeys[index]; + + if (!attributeName) return ""; + + const sheetCurrent = startingValues[attributeName]; + const sheetMax = startingValues[`${attributeName}_max`]; + const resultCurrent = targetValues[attributeName]; + const resultMax = targetValues[`${attributeName}_max`]; + + switch (key) { + case "NAME": + return attributeName; + case "TCUR": + return sheetCurrent !== undefined ? `${sheetCurrent}` : ""; + case "TMAX": + return sheetMax !== undefined ? `${sheetMax}` : ""; + case "CUR": { + const value = resultCurrent ?? sheetCurrent; + return value !== undefined ? `${value}` : ""; + } + case "MAX": { + const value = resultMax ?? sheetMax; + return value !== undefined ? `${value}` : ""; + } + default: + return ""; + } + }); + + return message; +}; + +function getChangedAttributeNames(targetValues: AttributeRecord): string[] { + const seen = new Set(); + const names: string[] = []; + + for (const key of Object.keys(targetValues)) { + const name = key.endsWith("_max") ? key.slice(0, -4) : key; + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + } + + return names; +} \ No newline at end of file diff --git a/ChatSetAttr/src/modules/help.ts b/ChatSetAttr/src/modules/help.ts new file mode 100644 index 0000000000..59b10973af --- /dev/null +++ b/ChatSetAttr/src/modules/help.ts @@ -0,0 +1,55 @@ +import { createHelpHandout } from "../templates/help/index"; +import { getBundledHelpContentUpdatedAt } from "../templates/help/loadContentRevision"; +import { getConfig, setConfig } from "./config"; + +export const HELP_COMMAND = "!setattr-help"; +export const HELP_HANDOUT_NAME = "ChatSetAttr Help"; + +export function checkHelpMessage(msg: string): boolean { + return msg.trim().toLowerCase().startsWith(HELP_COMMAND); +} + +export function findHelpHandout(): Roll20Handout | undefined { + return findObjs({ + _type: "handout", + name: HELP_HANDOUT_NAME, + })[0]; +} + +export function applyHelpContentToHandout(handout: Roll20Handout): void { + const helpContent = createHelpHandout(handout.id); + const bundledAt = getBundledHelpContentUpdatedAt(); + + handout.set({ + inplayerjournals: "all", + notes: helpContent, + }); + setConfig({ helpContentUpdatedAt: bundledAt }); +} + +export function handleHelpCommand(): void { + let handout = findHelpHandout(); + + if (!handout) { + handout = createObj("handout", { + name: HELP_HANDOUT_NAME, + }); + } + + applyHelpContentToHandout(handout); +} + +export function syncHelpHandoutOnStartup(): void { + const handout = findHelpHandout(); + if (!handout) { + return; + } + + const bundledAt = getBundledHelpContentUpdatedAt(); + const stateAt = getConfig().helpContentUpdatedAt; + if (stateAt >= bundledAt) { + return; + } + + applyHelpContentToHandout(handout); +} diff --git a/ChatSetAttr/src/modules/helpers.ts b/ChatSetAttr/src/modules/helpers.ts new file mode 100644 index 0000000000..a42b3a18fc --- /dev/null +++ b/ChatSetAttr/src/modules/helpers.ts @@ -0,0 +1,38 @@ +export function toStringOrUndefined(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + return String(value); +}; + +export function calculateBoundValue( + value?: number, + max?: number +): number { + if (value === undefined || max === undefined) { + return value || 0; + } + return Math.min(value, max); +}; + +export function cleanValue(value: string): string { + return value.trim().replace(/^['"](.*)['"]$/g, "$1"); +}; + +export function getCharName( + targetID: string +): string { + const character = getObj("character", targetID); + if (character) { + return character.get("name"); + } + return `ID: ${targetID}`; +}; + +export function asyncTimeout(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/inlinerolls.ts b/ChatSetAttr/src/modules/inlinerolls.ts new file mode 100644 index 0000000000..021c4d4aa5 --- /dev/null +++ b/ChatSetAttr/src/modules/inlinerolls.ts @@ -0,0 +1,54 @@ +type TableRollResult = { + tableItem?: { + name?: string; + }; +}; + +type TableRoll = { + table?: unknown; + results?: TableRollResult[]; +}; + +function inlineRollValue(roll: RollData): string | number { + const tableItems = roll.results.rolls.reduce((names, subRoll) => { + const tableSubRoll = subRoll as TableRoll; + if (!Object.prototype.hasOwnProperty.call(tableSubRoll, "table")) { + return names; + } + const subNames = (tableSubRoll.results ?? []) + .map(result => result.tableItem?.name ?? "") + .filter(Boolean); + if (subNames.length) { + names.push(subNames.join(", ")); + } + return names; + }, []); + const tableText = tableItems.filter(Boolean).join(", "); + return (tableText.length && tableText) || roll.results.total || 0; +} + +export function normalizeTemplateRollProperties(content: string): string { + return content + .replace( + /\{\{[^}[\]]+=\$?\[\[(\d+)\]\].*?\}\}/g, + (_, index) => `$[[${index}]]`, + ) + .replace( + /\{\{[^}=]+=([^}]+)\}\}/g, + (_, value: string) => value.trim(), + ); +} + +export function processInlinerolls( + msg: Pick, +): string { + if (!msg.inlinerolls?.length) { + return msg.content; + } + + const values = msg.inlinerolls.map(roll => String(inlineRollValue(roll))); + return values.reduce( + (content, value, index) => content.replace(`$[[${index}]]`, value), + msg.content, + ); +} diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts new file mode 100644 index 0000000000..ff11fc6ee1 --- /dev/null +++ b/ChatSetAttr/src/modules/main.ts @@ -0,0 +1,284 @@ +import scriptJson from "../../script.json" assert { type: "json" }; +import type { Attribute, AttributeRecord } from "../types"; +import { getAttributes } from "./attributes"; +import { sendDelayMessage, sendBeaconUnsupportedNotice, sendErrors, sendMessages, normalizeCommandOutputOptions } from "./chat"; +import { isBeaconSupported } from "./beaconSupport"; +import { handlers } from "./commands"; +import { checkConfigMessage, getConfig, handleConfigCommand, hasFlag } from "./config"; +import { + createFeedbackMessage, + formatDeleteFeedback, + formatSettingFeedback, +} from "./feedback"; +import { checkHelpMessage, handleHelpCommand } from "./help"; +import { getCharName } from "./helpers"; +import { processInlinerolls, normalizeTemplateRollProperties } from "./inlinerolls"; +import { extractMessageFromRollTemplate, normalizeMultilineCommand, parseMessage, validateMessage } from "./message"; +import { processModifications } from "./modifications"; +import { checkPermissions } from "./permissions"; +import { expandRepeatingRowDeletes, getAllRepOrders, getAllSectionNames } from "./repeating"; +import { generateTargets } from "./targets"; +import { clearTimer, startTimer } from "./timer"; +import { makeUpdate } from "./updates"; + +function broadcastHeader() { + log(`${scriptJson.name} v${scriptJson.version} by ${scriptJson.authors.join(", ")} loaded.`); +}; + +function checkDependencies(): boolean { + const errors: string[] = []; + if (libSmartAttributes === undefined) { + errors.push("libSmartAttributes is required but not found. Please ensure the libSmartAttributes script is installed."); + } + if (libUUID === undefined) { + errors.push("libUUID is required but not found. Please ensure the libUUID script is installed."); + } + if (errors.length > 0) { + sendErrors("gm", "Missing Dependencies", errors); + } + return errors.length === 0; +}; + +async function acceptMessage(msg: Roll20ChatMessage) { + // State + const errors: string[] = []; + const messages: string[] = []; + const result: Record = {}; + + // Parse Message + const parsed = parseMessage(msg.content); + if (!parsed) { + return errorOut( + "Could not parse command. Check that command options use -- (double dash).", + msg.playerid, + errors, + normalizeCommandOutputOptions(), + ); + } + + const { + operation, + targeting, + options, + changes, + references, + feedback, + } = parsed; + + const output = normalizeCommandOutputOptions(options); + + // Start Timer + startTimer("chatsetattr", 8000, () => sendDelayMessage(msg.playerid, output)); + + // Check Config and Permissions + const config = getConfig(); + const isAPI = "API" === msg.playerid; + const isGM = playerIsGM(msg.playerid); + + if (options.evaluate && !isAPI && !isGM && !config.playersCanEvaluate) { + return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors, output); + } + + if (targeting.includes("party") && !isAPI && !isGM && !config.playersCanTargetParty) { + return errorOut("You do not have permission to target the party.", msg.playerid, errors, output); + } + + if((operation === "modattr" || operation === "modbattr") && !isAPI && !isGM && !config.playersCanModify) { + return errorOut("You do not have permission to modify attributes.", msg.playerid, errors, output); + } + + // Preprocess + const { targets, errors: targetErrors } = generateTargets(msg, targeting); + errors.push(...targetErrors); + if (targets.length === 0) { + return errorOut("No valid targets found.", msg.playerid, errors, output); + } + + const request = generateRequest(references, changes); + const command = handlers[operation]; + + if (!command) { + return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors, output); + } + + // Execute + const priorValues: Record = {}; + const pendingChanges: Record = {}; + + for (const target of targets) { + const attrs = await getAttributes(target, request); + priorValues[target] = attrs; + const sectionNames = getAllSectionNames(changes); + const repOrders = await getAllRepOrders(target, sectionNames); + let effectiveChanges = changes; + if (operation === "delattr") { + effectiveChanges = expandRepeatingRowDeletes( + target, changes, repOrders, errors, getCharName(target), + ); + } + const modifications = processModifications( + effectiveChanges, attrs, options, repOrders, errors, getCharName(target), + ); + + const response = await command(modifications, target, references, options.nocreate, feedback); + + if (response.errors.length > 0) { + errors.push(...response.errors); + continue; + } + + pendingChanges[target] = modifications; + result[target] = response.result; + } + + const updateResult = await makeUpdate(operation, result, { + noCreate: options.nocreate, + priorValues, + operation, + }); + + clearTimer("chatsetattr"); + + errors.push(...updateResult.errors); + + for (const target in result) { + const filteredResult = filterSuccessfulResult(target, result[target], updateResult.failed); + if (Object.keys(filteredResult).length === 0) { + continue; + } + + const characterName = getCharName(target); + const targetChanges = pendingChanges[target] ?? []; + let message: string | null; + + if (feedback?.content) { + message = createFeedbackMessage( + characterName, + feedback, + priorValues[target] ?? {}, + filteredResult, + ); + } else if (operation === "delattr") { + message = formatDeleteFeedback(characterName, targetChanges, filteredResult); + } else { + message = formatSettingFeedback(characterName, targetChanges, filteredResult); + } + + if (message) { + messages.push(message); + } + } + + sendErrors(msg.playerid, "Errors", errors, feedback?.from, output); + const delSetTitle = operation === "delattr" ? "Deleting attributes" : "Setting attributes"; + const feedbackTitle = feedback?.header ?? delSetTitle; + if (messages.length > 0) { + sendMessages(msg.playerid, feedbackTitle, messages, { + from: feedback?.from, + public: feedback?.public, + }, output); + } +}; + +function errorOut( + errorText: string, + playerid: string, + errors: string[], + output: ReturnType, +) { + errors.push(errorText); + sendErrors(playerid, "Errors", errors, undefined, output); + clearTimer("chatsetattr"); +} + +function filterSuccessfulResult( + target: string, + targetResult: AttributeRecord, + failed: string[], +): AttributeRecord { + const filtered: AttributeRecord = {}; + + for (const key in targetResult) { + if (!failed.includes(`${target}:${key}`)) { + filtered[key] = targetResult[key]; + } + } + + return filtered; +} + + +export function generateRequest( + references: string[], + changes: Attribute[], +): string[] { + const referenceSet = new Set(references); + for (const change of changes) { + if (!change.name) { + continue; + } + if (!referenceSet.has(change.name)) { + referenceSet.add(change.name); + } + const maxName = `${change.name}_max`; + if (!referenceSet.has(maxName)) { + referenceSet.add(maxName); + } + } + return Array.from(referenceSet); +}; + +export function registerHandlers() { + broadcastHeader(); + if (!checkDependencies()) { + return; + } + + if (!isBeaconSupported()) { + sendBeaconUnsupportedNotice(); + } + + on("chat:message", (msg) => { + if (msg.type !== "api") { + const inlineMessage = extractMessageFromRollTemplate(msg); + if (!inlineMessage) return; + msg.content = inlineMessage; + } + msg.content = normalizeMultilineCommand(msg.content); + msg.content = normalizeTemplateRollProperties(msg.content); + msg.content = processInlinerolls(msg); + const debugReset = msg.content.startsWith("!setattrs-debugreset"); + if (debugReset) { + log("ChatSetAttr: Debug - resetting state."); + state.ChatSetAttr = {}; + return; + } + const debugVersion = msg.content.startsWith("!setattrs-debugversion"); + if (debugVersion) { + log("ChatSetAttr: Debug - setting state schema version to 3."); + if (!state.ChatSetAttr) state.ChatSetAttr = {}; + state.ChatSetAttr.version = 3; + return; + } + const isHelpMessage = checkHelpMessage(msg.content); + if (isHelpMessage) { + handleHelpCommand(); + return; + } + const isConfigMessage = checkConfigMessage(msg.content); + if (isConfigMessage) { + if (!playerIsGM(msg.playerid)) { + return; + } + handleConfigCommand(msg.content, msg.playerid); + return; + } + const validMessage = validateMessage(msg.content); + if (!validMessage) return; + if (checkPermissions(msg.playerid)) { + acceptMessage(msg); + } + }); +}; + +export { registerObserver } from "./observer"; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/message.ts b/ChatSetAttr/src/modules/message.ts new file mode 100644 index 0000000000..2bf43ba0a1 --- /dev/null +++ b/ChatSetAttr/src/modules/message.ts @@ -0,0 +1,169 @@ +import { + COMMAND_TYPE, + extractFeedbackKey, + isCommand, + isCommandOption, + isFeedbackOption, + isOption, + OVERRIDE_DICTIONARY, + TARGETS, + type Attribute, + type Command, + type FeedbackObject, + type OptionsRecord +} from "../types"; +import { cleanValue } from "./helpers"; + +// #region Inline Message Extraction and Validation +export function validateMessage(content: string): boolean { + for (const command of COMMAND_TYPE) { + const messageCommand = content.split(" ")[0]; + if (messageCommand === `!${command}`) { + return true; + } + } + return false; +}; + +export function extractMessageFromRollTemplate(msg: Roll20ChatMessage): string | false { + for (const command of COMMAND_TYPE) { + if (msg.content.includes(command)) { + const regex = new RegExp(`(!${command}.*?)!!!`, "gi"); + const match = regex.exec(msg.content); + if (match) return match[1].trim(); + } + } + return false; +}; + +// Roll20 delivers multiline commands wrapped in `{{ ... }}` with HTML line +// breaks (`
    `). This mirrors ChatSetAttr 1.10's `parseOpts` preprocessing so +// a `{{ }}`-wrapped multiline command parses identically to its flat form. +export function normalizeMultilineCommand(content: string): string { + return content + .replace(//g, "") + .replace(/\s+$/g, "") + .replace(/\s*{{((?:.|\n)*)\s+}}$/, " $1") + .replace(/\\([{}])/g, "$1"); +}; + +// #region Message Parsing +function extractOperation(parts: string[]): Command | undefined { + if (parts.length === 0) { + log("Empty Command."); + return; + } + const commandPart = parts.shift()!; + const tokens = commandPart.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) { + log("Empty Command."); + return; + } + if (!tokens[0].startsWith("!")) { + log("Invalid Command."); + return; + } + const command = tokens[0].slice(1); + if (!isCommand(command)) { + log("Invalid Command."); + return; + } + if (tokens.length > 1) { + parts.unshift(tokens.slice(1).join(" ")); + } + return command; +}; + +function extractReferences(value: string | number | boolean): string[] { + if (typeof value !== "string") return []; + const matches = value.matchAll(/%[a-zA-Z0-9_]+%/g); + return Array.from(matches, m => m[0]); +}; + +function splitMessage(content: string): string[] { + const split = content.split("--").map(part => part.trim()); + return split; +}; + +function includesATarget(part: string): boolean { + if (part.includes("|") || part.includes("#")) return false; + [ part ] = part.split(" ").map(p => p.trim()); + for (const target of TARGETS) { + const isMatch = part.toLowerCase() === target.toLowerCase(); + if (isMatch) return true; + } + return false; +}; + +export function parseMessage(content: string) { + content = normalizeMultilineCommand(content); + const parts = splitMessage(content); + let operation = extractOperation(parts); + if (!operation) { + return; + } + + const targeting: string[] = []; + const options: OptionsRecord = {} as OptionsRecord; + const changes: Attribute[] = []; + const references: string[] = []; + const feedback: FeedbackObject = { public: false }; + + for (const part of parts) { + if (isCommandOption(part)) { + operation = OVERRIDE_DICTIONARY[part]; + } + + else if (isOption(part)) { + options[part] = true; + } + + else if (includesATarget(part)) { + targeting.push(part); + } + + else if (isFeedbackOption(part)) { + const [ key, ...valueParts ] = part.split(" "); + const value = valueParts.join(" "); + const feedbackKey = extractFeedbackKey(key); + if (!feedbackKey) continue; + if (feedbackKey === "public") { + feedback.public = true; + } else { + feedback[feedbackKey] = cleanValue(value); + } + } + + else if (part.includes("|") || part.includes("#")) { + const split = part.split(/[|#]/g).map(p => p.trim()); + const [attrName, attrCurrent, attrMax] = split; + if (!attrName && !attrCurrent && !attrMax) { + continue; + } + const attribute: Attribute = {}; + if (attrName) attribute.name = attrName; + if (attrCurrent) attribute.current = cleanValue(attrCurrent); + if (attrMax) attribute.max = cleanValue(attrMax); + changes.push(attribute); + + const currentMatches = extractReferences(attrCurrent); + const maxMatches = extractReferences(attrMax); + references.push(...currentMatches, ...maxMatches); + } + + else { + const suspectedAttribute = part.replace(/[^-0-9A-Za-z_$]/g, ""); + if (!suspectedAttribute) continue; + changes.push({ name: suspectedAttribute }); + } + } + + return { + operation, + options, + targeting, + changes, + references, + feedback, + }; +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/modifications.ts b/ChatSetAttr/src/modules/modifications.ts new file mode 100644 index 0000000000..e9a73b4963 --- /dev/null +++ b/ChatSetAttr/src/modules/modifications.ts @@ -0,0 +1,161 @@ +import { ALIAS_CHARACTERS, type Attribute, type AttributeRecord, type OptionsRecord } from "../types"; +import { extractRepeatingParts, hasCreateIdentifier, hasIndexIdentifier } from "./repeating"; + +export type ProcessModifierOptions = { + shouldEvaluate?: boolean; + shouldAlias?: boolean; +}; + +export function processModifierValue( + modification: string, + resolvedAttributes: AttributeRecord, + { + shouldEvaluate = false, + shouldAlias = false + }: ProcessModifierOptions = {} +): string { + let finalValue = replacePlaceholders(modification, resolvedAttributes); + + if (shouldAlias) { + finalValue = replaceAliasCharacters(finalValue); + } + + if (shouldEvaluate) { + finalValue = evaluateExpression(finalValue); + } + + return finalValue; +}; + +function replaceAliasCharacters( + modification: string, +): string { + let result = modification; + for (const alias in ALIAS_CHARACTERS) { + const original = ALIAS_CHARACTERS[alias]; + const regex = new RegExp(`\\${alias}`, "g"); + result = result.replace(regex, original); + } + return result; +}; + +function replacePlaceholders( + value: string, + attributes: AttributeRecord +): string { + if (typeof value !== "string") return value; + return value.replace(/%([a-zA-Z0-9_]+)%/g, (match, name) => { + const replacement = attributes[name]; + return replacement !== undefined ? String(replacement) : match; + }); +}; + +function evaluateExpression( + expression: string, +): string { + try { + const stringValue = String(expression); + const result = eval(stringValue); + return result; + } catch { + return expression; + } +}; + +export type ProcessModifierNameOptions = { + repeatingID?: string; + repOrder: string[]; +}; + +export function processModifierName( + name: string, + { repeatingID, repOrder }: ProcessModifierNameOptions +): string { + let result = name; + const hasCreate = result.includes("CREATE"); + if (hasCreate && repeatingID) { + if (/-CREATE/i.test(result)) { + result = result.replace(/-CREATE/i, repeatingID); + } else { + result = result.replace(/CREATE/i, repeatingID); + } + } + + const rowIndexMatch = result.match(/\$(\d+)/); + if (rowIndexMatch && repOrder) { + const rowIndex = parseInt(rowIndexMatch[1], 10); + const rowID = repOrder[rowIndex]; + if (!rowID) return result; + result = result.replace(`$${rowIndex}`, rowID); + } + + return result; +}; + +export function processModifications( + modifications: Attribute[], + resolved: AttributeRecord, + options: OptionsRecord, + repOrders: Record, + errors: string[] = [], + characterName = "", +): Attribute[] { + const processedModifications: Attribute[] = []; + const repeatingID = libUUID.generateRowID(); + + for (const mod of modifications) { + if (!mod.name) continue; + let processedName = mod.name; + const parts = extractRepeatingParts(mod.name); + if (parts) { + const hasCreate = hasCreateIdentifier(parts.identifier); + const repOrder = repOrders[parts.section] || []; + processedName = processModifierName(mod.name, { + repeatingID: hasCreate ? repeatingID : parts.identifier, + repOrder, + }); + + if (hasIndexIdentifier(mod.name)) { + const unresolvedIndex = processedName.match(/\$(\d+)/); + if (unresolvedIndex) { + errors.push( + `Repeating row number ${unresolvedIndex[1]} invalid for character ${characterName} and repeating section repeating_${parts.section}.`, + ); + continue; + } + } + } + + let processedCurrent = undefined; + if (mod.current !== undefined && mod.current !== "undefined") { + processedCurrent = String(mod.current); + processedCurrent = processModifierValue(processedCurrent, resolved, { + shouldEvaluate: options.evaluate, + shouldAlias: options.replace, + }); + } + + let processedMax = undefined; + if (mod.max !== undefined) { + processedMax = String(mod.max); + processedMax = processModifierValue(processedMax, resolved, { + shouldEvaluate: options.evaluate, + shouldAlias: options.replace, + }); + } + + const processedMod: Attribute = { + name: processedName, + }; + if (processedCurrent !== undefined) { + processedMod.current = processedCurrent; + } + if (processedMax !== undefined) { + processedMod.max = processedMax; + } + + processedModifications.push(processedMod); + } + + return processedModifications; +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/observer.ts b/ChatSetAttr/src/modules/observer.ts new file mode 100644 index 0000000000..7e79cd50f3 --- /dev/null +++ b/ChatSetAttr/src/modules/observer.ts @@ -0,0 +1,30 @@ +import type { + ObserverAttributeSnapshot, + ObserverCallback, + ObserverCallbackTarget, + ObserverEvent, + ObserverRecord, +} from "../types"; + +const observers: ObserverRecord = {}; + +export function registerObserver( + event: ObserverEvent, + callback: ObserverCallback +): void { + if (!observers[event]) { + observers[event] = []; + } + observers[event].push(callback); +}; + +export function notifyObservers( + event: ObserverEvent, + obj: ObserverCallbackTarget, + prev?: ObserverAttributeSnapshot +): void { + const callbacks = observers[event] || []; + callbacks.forEach(callback => { + callback(obj, prev); + }); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/observerPayload.ts b/ChatSetAttr/src/modules/observerPayload.ts new file mode 100644 index 0000000000..83b1d06f94 --- /dev/null +++ b/ChatSetAttr/src/modules/observerPayload.ts @@ -0,0 +1,369 @@ +import type { + AttributeRecord, + AttributeValue, + ObserverAttributeKind, + ObserverAttributeObject, + ObserverAttributeSnapshot, + ObserverCallbackTarget, +} from "../types"; + +export type MergedAttributeState = { + current: string; + max: string; + priorCurrent: string; + priorMax: string; +}; + +const WRITABLE_KEYS = new Set(["current", "max"]); + +function normalizeKey(key: string): string { + return key.startsWith("_") ? key.slice(1) : key; +}; + +function toAttrString(value: AttributeValue | undefined): string { + if (value === undefined || value === null) { + return ""; + } + return String(value); +}; + +function hasSheetItemValue(value: unknown): boolean { + return value !== null && value !== undefined && value !== ""; +}; + +function hasPriorValue(value: AttributeValue | undefined): boolean { + return value !== undefined && value !== null && value !== ""; +}; + +export function toSnapshot( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, + state: Pick, + id = "", +): ObserverAttributeSnapshot { + return { + _id: id, + _type: kind, + _characterid: targetId, + name: actualName, + current: state.current, + max: state.max, + }; +}; + +export function emptySnapshot( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, + id = "", +): ObserverAttributeSnapshot { + return toSnapshot(targetId, actualName, kind, { current: "", max: "" }, id); +}; + +export function mergeAttributeState( + targetId: string, + actualName: string, + priorValues: Record, + results: Record, + isDelete: boolean, +): MergedAttributeState { + const maxKey = `${actualName}_max`; + const priorCurrent = priorValues[targetId]?.[actualName]; + const priorMax = priorValues[targetId]?.[maxKey]; + + if (isDelete) { + return { + current: toAttrString(priorCurrent), + max: toAttrString(priorMax), + priorCurrent: toAttrString(priorCurrent), + priorMax: toAttrString(priorMax), + }; + } + + const newCurrent = results[targetId]?.[actualName]; + const newMax = results[targetId]?.[maxKey]; + + return { + current: newCurrent !== undefined ? toAttrString(newCurrent) : toAttrString(priorCurrent), + max: newMax !== undefined ? toAttrString(newMax) : toAttrString(priorMax), + priorCurrent: toAttrString(priorCurrent), + priorMax: toAttrString(priorMax), + }; +}; + +export function tryFindLegacyAttribute( + targetId: string, + actualName: string, +): Roll20Attribute | undefined { + return findObjs({ + _type: "attribute", + _characterid: targetId, + name: actualName, + })[0]; +}; + +export function isLegacySheet(targetId: string): boolean { + const character = getObj("character", targetId); + if (!character) { + return false; + } + return character.sheetEnvironment === "legacy" || character.sheetEnvironment === undefined; +}; + +function legacyAttributeForSheet( + targetId: string, + actualName: string, +): Roll20Attribute | undefined { + if (!isLegacySheet(targetId)) { + return undefined; + } + return tryFindLegacyAttribute(targetId, actualName); +}; + +export async function resolveObserverKind( + targetId: string, + actualName: string, +): Promise { + if (isLegacySheet(targetId)) { + return "attribute"; + } + + const computed = await getSheetItem(targetId, actualName, "current"); + const computedMax = await getSheetItem(targetId, actualName, "max"); + if (hasSheetItemValue(computed) || hasSheetItemValue(computedMax)) { + return "computed"; + } + + const userAttr = await getSheetItem(targetId, `user.${actualName}`, "current"); + const userMax = await getSheetItem(targetId, `user.${actualName}`, "max"); + if (hasSheetItemValue(userAttr) || hasSheetItemValue(userMax)) { + return "userAttribute"; + } + + return "computed"; +}; + +export function isNewAttributeOrUser( + kind: ObserverAttributeKind, + state: MergedAttributeState, +): boolean { + if (kind === "computed") { + return false; + } + return state.priorCurrent === "" && state.priorMax === ""; +}; + +function sheetItemPath(kind: ObserverAttributeKind, actualName: string): string { + return kind === "userAttribute" ? `user.${actualName}` : actualName; +}; + +async function writeSheetItemValue( + characterId: string, + kind: ObserverAttributeKind, + actualName: string, + key: string, + value: string, +): Promise { + const normalized = normalizeKey(key); + if (!WRITABLE_KEYS.has(normalized)) { + return false; + } + + const type = normalized as "current" | "max"; + const path = sheetItemPath(kind, actualName); + + try { + await setSheetItem(characterId, path, value, type, { + allowThrow: true, + createAttr: true, + withWorker: true, + }); + return true; + } catch { + return false; + } +}; + +export function createObserverAttributeObject( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, + state: Pick, + id = "", +): ObserverAttributeObject { + const snapshot = toSnapshot(targetId, actualName, kind, state, id); + + const obj: ObserverAttributeObject = { + get(key: string) { + const normalized = normalizeKey(key); + const byKey: Record = { + id: snapshot._id, + _id: snapshot._id, + type: snapshot._type, + _type: snapshot._type, + characterid: snapshot._characterid, + _characterid: snapshot._characterid, + name: snapshot.name, + current: snapshot.current, + max: snapshot.max, + }; + return byKey[normalized] ?? byKey[key]; + }, + + set(keyOrProps: string | Partial, value?: string) { + const updates: Partial> = {}; + + if (typeof keyOrProps === "string") { + const normalized = normalizeKey(keyOrProps); + if (WRITABLE_KEYS.has(normalized) && value !== undefined) { + updates[normalized as "current" | "max"] = value; + } + } else { + if (keyOrProps.current !== undefined) { + updates.current = keyOrProps.current; + } + if (keyOrProps.max !== undefined) { + updates.max = keyOrProps.max; + } + } + + for (const [key, nextValue] of Object.entries(updates)) { + if (nextValue === undefined) { + continue; + } + void writeSheetItemValue(targetId, kind, actualName, key, nextValue).then(ok => { + if (ok) { + snapshot[key as "current" | "max"] = nextValue; + } + }); + } + + return obj; + }, + + toJSON() { + return { ...snapshot }; + }, + }; + + return obj; +}; + +export function resolveObserverDestroyObj( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, +): ObserverCallbackTarget | undefined { + if (kind !== "attribute" || !isLegacySheet(targetId)) { + return undefined; + } + return tryFindLegacyAttribute(targetId, actualName); +}; + +export function resolveObserverObj( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, + state: MergedAttributeState, +): ObserverCallbackTarget { + if (kind === "attribute") { + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + return legacyAttr; + } + } + + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + const id = legacyAttr?.get("_id") ?? ""; + return createObserverAttributeObject(targetId, actualName, kind, state, id); +}; + +export function resolveObserverAddObj( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, + state: Pick, +): ObserverCallbackTarget { + if (kind === "attribute") { + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + return legacyAttr; + } + } + + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + const id = legacyAttr?.get("_id") ?? ""; + return createObserverAttributeObject(targetId, actualName, kind, state, id); +}; + +export async function captureDeletePriorState( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, + priorValues: Record, +): Promise { + const maxKey = `${actualName}_max`; + let priorCurrent = priorValues[targetId]?.[actualName]; + let priorMax = priorValues[targetId]?.[maxKey]; + + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + if (!hasPriorValue(priorCurrent)) { + priorCurrent = legacyAttr.get("current"); + } + if (!hasPriorValue(priorMax)) { + priorMax = legacyAttr.get("max"); + } + } else { + const userCurrent = await getSheetItem(targetId, `user.${actualName}`, "current"); + const userMax = await getSheetItem(targetId, `user.${actualName}`, "max"); + const hasUserValues = hasSheetItemValue(userCurrent) || hasSheetItemValue(userMax); + const path = hasUserValues || kind === "userAttribute" + ? `user.${actualName}` + : actualName; + + if (!hasPriorValue(priorCurrent)) { + priorCurrent = await getSheetItem(targetId, path, "current"); + } + if (!hasPriorValue(priorMax)) { + priorMax = await getSheetItem(targetId, path, "max"); + } + + if (!hasPriorValue(priorCurrent) && hasUserValues) { + priorCurrent = userCurrent; + } + if (!hasPriorValue(priorMax) && hasUserValues) { + priorMax = userMax; + } + } + + const current = toAttrString(priorCurrent); + const max = toAttrString(priorMax); + return { + current, + max, + priorCurrent: current, + priorMax: max, + }; +}; + +export function logicalAttributeKey(target: string, actualName: string): string { + return `${target}:${actualName}`; +}; + +export function parseResultKey(key: string): { target: string; name: string } { + const separator = key.indexOf(":"); + return { + target: key.slice(0, separator), + name: key.slice(separator + 1), + }; +}; + +export function toActualName(name: string): { actualName: string; isMax: boolean } { + const isMax = name.endsWith("_max"); + return { + actualName: isMax ? name.slice(0, -4) : name, + isMax, + }; +}; diff --git a/ChatSetAttr/src/modules/permissions.ts b/ChatSetAttr/src/modules/permissions.ts new file mode 100644 index 0000000000..c4518278b8 --- /dev/null +++ b/ChatSetAttr/src/modules/permissions.ts @@ -0,0 +1,61 @@ +import { getConfig } from "./config"; + +const permissions = { + playerID: "", + isGM: false, + canModify: false, +}; + +export function checkPermissions(playerID: string): boolean { + const player = getObj("player", playerID); + if (!player) { + if("API" === playerID) { + // allow API full access + setPermissions(playerID,true,true); + return true; + } + log(`Player with ID ${playerID} not found.`); + return false; + } + const isGM = playerIsGM(playerID); + const config = getConfig(); + const playersCanModify = config.playersCanModify || false; + const canModify = isGM || playersCanModify; + + setPermissions(playerID, isGM, canModify); + return true; +}; + +export function setPermissions(playerID: string, isGM: boolean, canModify: boolean) { + permissions.playerID = playerID; + permissions.isGM = isGM; + permissions.canModify = canModify; +}; + +export function getPermissions() { + return { ...permissions }; +}; + +export function checkPermissionForTarget(playerID: string, target: string): boolean { + const isAPI = "API" == playerID; + if (isAPI) { + return true; + } + const player = getObj("player", playerID); + if (!player) { + return false; + } + const isGM = playerIsGM(playerID); + if (isGM) { + return true; + } + if (getConfig().playersCanModify) { + return true; + } + const character = getObj("character", target); + if (!character) { + return false; + } + const controlledBy = (character.get("controlledby") || "").split(","); + return controlledBy.includes(playerID); +}; diff --git a/ChatSetAttr/src/modules/repeating.ts b/ChatSetAttr/src/modules/repeating.ts new file mode 100644 index 0000000000..16604a9c95 --- /dev/null +++ b/ChatSetAttr/src/modules/repeating.ts @@ -0,0 +1,387 @@ +import type { Attribute } from "../types"; + +export const REPEATING_INDEX_TOKEN = /^\$(\d+)$/i; +export const REPEATING_CREATE_TOKEN = /^CREATE$/i; +export const REPEATING_DASH_CREATE_TOKEN = /^-CREATE$/i; + +export function isRepeatingCreateToken(token: string): boolean { + return REPEATING_CREATE_TOKEN.test(token) || REPEATING_DASH_CREATE_TOKEN.test(token); +}; + +export type RepeatingIdentifierToken = + | { kind: "index"; index: number } + | { kind: "create" } + | { kind: "rowId"; rowId: string }; + +export function parseRepeatingIdentifierToken( + token: string, +): RepeatingIdentifierToken | null { + if (!token) return null; + const indexMatch = token.match(REPEATING_INDEX_TOKEN); + if (indexMatch) { + return { kind: "index", index: Number(indexMatch[1]) }; + } + if (isRepeatingCreateToken(token)) { + return { kind: "create" }; + } + return { kind: "rowId", rowId: token }; +}; + +export function isRepeatingRowIdToken(token: string): boolean { + const parsed = parseRepeatingIdentifierToken(token); + return parsed?.kind === "rowId"; +}; + +export function resolveRowIdInRepOrder( + repOrder: string[], + rowId: string, +): string | null { + const rowIdLo = rowId.toLowerCase(); + const index = repOrder.findIndex(id => id.toLowerCase() === rowIdLo); + if (index === -1) return null; + return repOrder[index]; +}; + +export type RepeatingRowDeleteTarget = { + sectionPrefix: string; + rowIndex?: number; + rowId?: string; +}; + +export function parseRepeatingRowDeleteTarget( + name: string, +): RepeatingRowDeleteTarget | null { + if (extractRepeatingParts(name)) { + return null; + } + const parts = name.split("_"); + if (parts.length !== 3) { + return null; + } + const [repeating, section, identifierToken] = parts; + if (repeating !== "repeating" || !section || !identifierToken) { + return null; + } + const parsed = parseRepeatingIdentifierToken(identifierToken); + if (!parsed || parsed.kind === "create") { + return null; + } + const sectionPrefix = `repeating_${section}`; + if (parsed.kind === "index") { + return { sectionPrefix, rowIndex: parsed.index }; + } + return { sectionPrefix, rowId: parsed.rowId }; +}; + +export function getSectionFromRepeatingPrefix( + sectionPrefix: string, +): string | null { + const match = sectionPrefix.match(/^repeating_(.+)$/); + return match ? match[1] : null; +}; + +export function resolveRepeatingRowId( + target: RepeatingRowDeleteTarget, + repOrder: string[], +): string | null { + if (target.rowIndex !== undefined) { + if (target.rowIndex < 0 || target.rowIndex >= repOrder.length) { + return null; + } + return repOrder[target.rowIndex]; + } + if (target.rowId) { + return resolveRowIdInRepOrder(repOrder, target.rowId); + } + return null; +}; + +export function findRepeatingRowAttributeNames( + characterID: string, + sectionPrefix: string, + rowId: string, +): string[] { + const prefix = `${sectionPrefix}_${rowId}_`.toUpperCase(); + const attributes = findObjs({ + _type: "attribute", + _characterid: characterID, + }); + const names: string[] = []; + for (const attribute of attributes) { + const name = attribute.get("name"); + if (typeof name !== "string") continue; + if (name.toUpperCase().startsWith(prefix)) { + names.push(name); + } + } + return names; +}; + +export function expandRepeatingRowDeletes( + characterID: string, + changes: Attribute[], + repOrders: Record, + errors: string[], + characterName: string, +): Attribute[] { + const result: Attribute[] = []; + for (const change of changes) { + if (!change.name) continue; + const target = parseRepeatingRowDeleteTarget(change.name); + if (!target) { + result.push(change); + continue; + } + const section = getSectionFromRepeatingPrefix(target.sectionPrefix); + if (!section) { + result.push(change); + continue; + } + const repOrder = repOrders[section] || []; + const resolvedRowId = resolveRepeatingRowId(target, repOrder); + if (!resolvedRowId) { + if (target.rowIndex !== undefined) { + errors.push( + `Repeating row number ${target.rowIndex} invalid for character ${characterName} and repeating section ${target.sectionPrefix}.`, + ); + } else { + errors.push( + `Repeating row id ${target.rowId} invalid for character ${characterName} and repeating section ${target.sectionPrefix}.`, + ); + } + continue; + } + const fieldNames = findRepeatingRowAttributeNames( + characterID, + target.sectionPrefix, + resolvedRowId, + ); + for (const name of fieldNames) { + result.push({ name }); + } + } + return result; +}; + +export type RepeatingParts = { + section: string; + identifier: string; + field: string; +}; + +export function extractRepeatingParts( + attributeName: string +): RepeatingParts | null { + const [repeating, section, identifier, ...fieldParts] = attributeName.split("_"); + if (repeating !== "repeating") { + return null; + } + const field = fieldParts.join("_"); + if (!section || !identifier || !field) { + return null; + } + return { + section, + identifier, + field + }; +}; + +export function combineRepeatingParts( + parts: RepeatingParts +): string { + if (!parts.section || !parts.identifier || !parts.field) { + throw new Error("[CHATSETATTR] combineRepeatingParts: All parts (section, identifier, field) must be non-empty strings."); + } + return `repeating_${parts.section}_${parts.identifier}_${parts.field}`; +}; + +export function isRepeatingAttribute( + attributeName: string +): boolean { + const parts = extractRepeatingParts(attributeName); + return parts !== null; +}; + +export function hasCreateIdentifier( + attributeName: string +): boolean { + const parts = extractRepeatingParts(attributeName); + if (parts) { + return isRepeatingCreateToken(parts.identifier); + } + return isRepeatingCreateToken(attributeName); +}; + +export function hasIndexIdentifier( + attributeName: string +): boolean { + const parts = extractRepeatingParts(attributeName); + if (!parts) return false; + return REPEATING_INDEX_TOKEN.test(parts.identifier); +}; + +export function convertRepOrderToArray( + repOrder: string +): string[] { + return repOrder.split(",").map(id => id.trim()).filter(Boolean); +}; + +export function discoverRowIds( + characterID: string, + section: string, +): string[] { + const rowIds = new Set(); + const attributes = findObjs({ + _type: "attribute", + _characterid: characterID, + }); + + for (const attribute of attributes) { + const name = attribute.get("name"); + if (typeof name !== "string") continue; + const parts = name.split("_"); + if (parts.length < 4) continue; + if (parts[0] !== "repeating" || parts[1] !== section) continue; + const identifier = parts[2]; + if (isRepeatingRowIdToken(identifier)) { + rowIds.add(identifier); + } + } + + return Array.from(rowIds); +}; + +export function mergeRepOrder( + storedOrder: string[], + discoveredIds: string[], +): string[] { + const discoveredSet = new Set(discoveredIds); + const ordered = storedOrder.filter(id => discoveredSet.has(id)); + for (const id of discoveredIds) { + if (!ordered.includes(id)) { + ordered.push(id); + } + } + return ordered; +}; + +export function getIDFromIndex( + attributeName: string, + repOrder: string[], +): string | null { + const parts = extractRepeatingParts(attributeName); + if (!parts) return null; + const hasIndex = hasIndexIdentifier(attributeName); + if (!hasIndex) return null; + + const match = parts.identifier.match(/^\$(\d+)$/); + if (!match) return null; + + const index = Number(match[1]); + if (isNaN(index) || index < 0 || index >= repOrder.length) { + return null; + } + return repOrder[index]; +}; + +export async function getRepOrderForSection( + characterID: string, + section: string, +) { + const repOrderAttribute = `_reporder_repeating_${section}`; + const repOrder = await libSmartAttributes.getAttribute(characterID, repOrderAttribute); + return repOrder; +}; + +export function extractRepeatingAttributes( + attributes: Attribute[] +): Attribute[] { + return attributes.filter(attr => attr.name && isRepeatingAttribute(attr.name)); +}; + +export function getAllSectionNames( + attributes: Attribute[] +): string[] { + const sectionNames: Set = new Set(); + for (const attr of attributes) { + if (!attr.name) continue; + const parts = extractRepeatingParts(attr.name); + if (parts) { + sectionNames.add(parts.section); + continue; + } + const rowDelete = parseRepeatingRowDeleteTarget(attr.name); + if (rowDelete) { + const section = getSectionFromRepeatingPrefix(rowDelete.sectionPrefix); + if (section) { + sectionNames.add(section); + } + } + } + return Array.from(sectionNames); +}; + +export async function getAllRepOrders( + characterID: string, + sectionNames: string[], +) { + const repOrders: Record = {}; + for (const section of sectionNames) { + const repOrderString = await getRepOrderForSection(characterID, section); + const stored = repOrderString && typeof repOrderString === "string" + ? convertRepOrderToArray(repOrderString) + : []; + const discovered = discoverRowIds(characterID, section); + repOrders[section] = mergeRepOrder(stored, discovered); + } + return repOrders; +}; + +export async function processRepeatingAttributes( + characterID: string, + attributes: Attribute[], +) { + const repeatingAttributes = extractRepeatingAttributes(attributes); + const sectionNames = getAllSectionNames(repeatingAttributes); + const repOrders = await getAllRepOrders(characterID, sectionNames); + + const processedAttributes: Attribute[] = []; + + for (const attr of repeatingAttributes) { + if (!attr.name) continue; + const parts = extractRepeatingParts(attr.name); + if (!parts) continue; + + let identifier = parts.identifier; + + const useIndex = hasIndexIdentifier(attr.name); + if (useIndex) { + const repOrderForSection = repOrders[parts.section]; + const rowID = getIDFromIndex(attr.name, repOrderForSection); + if (rowID) { + identifier = rowID; + } else { + continue; + } + } + + const useNewID = hasCreateIdentifier(attr.name); + if (useNewID) { + identifier = libUUID.generateRowID(); + } + + const combinedName = combineRepeatingParts({ + section: parts.section, + identifier, + field: parts.field + }); + + processedAttributes.push({ + ...attr, + name: combinedName + }); + } + + return processedAttributes; +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/targets.ts b/ChatSetAttr/src/modules/targets.ts new file mode 100644 index 0000000000..0fd4daae4a --- /dev/null +++ b/ChatSetAttr/src/modules/targets.ts @@ -0,0 +1,251 @@ +import type { Target } from "../types"; +import { getConfig } from "./config"; +import { checkPermissionForTarget, getPermissions } from "./permissions"; + +function generateSelectedTargets(message: Roll20ChatMessage, type: Target) { + const errors: string[] = []; + const targets: string[] = []; + + if (!message.selected) return { targets, errors }; + + for (const token of message.selected) { + const tokenObj = getObj("graphic", token._id); + if (!tokenObj) { + errors.push(`Selected token with ID ${token._id} not found.`); + continue; + } + if (tokenObj.get("_subtype") !== "token") { + errors.push(`Selected object with ID ${token._id} is not a token.`); + continue; + } + + const represents = tokenObj.get("represents"); + const character = getObj("character", represents); + if (!character) { + errors.push(`Token with ID ${token._id} does not represent a character.`); + continue; + } + + const inParty = (character.get as (key: string) => unknown)("inParty"); + if (type === "sel-noparty" && inParty) { + continue; + } + if (type === "sel-party" && !inParty) { + continue; + } + + targets.push(character.id); + } + + return { + targets, + errors, + }; +}; + +function generateAllTargets(type: Target) { + const { isGM } = getPermissions(); + const errors: string[] = []; + + if (!isGM) { + errors.push(`Only GMs can use the '${type}' target option.`); + return { + targets: [], + errors, + }; + } + + const characters = findObjs({ _type: "character" }); + if (type === "all") { + return { + targets: characters.map(char => char.id), + errors, + }; + } + + else if (type === "allgm") { + const targets = characters.filter(char => { + const controlledBy = char.get("controlledby"); + return !controlledBy; + }).map(char => char.id); + return { + targets, + errors, + }; + } + + else if (type === "allplayers") { + const targets = characters.filter(char => { + const controlledBy = char.get("controlledby"); + return !!controlledBy; + }).map(char => char.id); + + return { + targets, + errors, + }; + } + + return { + targets: [], + errors: [`Unknown target type '${type}'.`], + }; +}; + +function generateCharacterIDTargets(values: string[]) { + const { playerID } = getPermissions(); + const targets: string[] = []; + const errors: string[] = []; + + for (const charID of values) { + const character = getObj("character", charID); + if (!character) { + errors.push(`Character with ID ${charID} not found.`); + continue; + } + const characterID = character.id; + const hasPermission = checkPermissionForTarget(playerID, characterID); + if (!hasPermission) { + errors.push(`Permission error. You do not have permission to modify character with ID ${charID}.`); + continue; + } + targets.push(characterID); + } + + return { + targets, + errors, + }; +}; + +function generatePartyTargets() { + const { isGM } = getPermissions(); + const { playersCanTargetParty } = getConfig(); + const targets: string[] = []; + const errors: string[] = []; + + if (!isGM && !playersCanTargetParty) { + errors.push("Only GMs can use the 'party' target option."); + return { + targets, + errors, + }; + } + + const characters = findObjs( + { _type: "character", inParty: true } as + Partial & { _type: "character"; inParty?: boolean }, + ); + for (const character of characters) { + const characterID = character.id; + targets.push(characterID); + } + + return { + targets, + errors, + }; +}; + + +function splitCommaSeparatedValues(valueString: string): string[] { + if (!valueString) { + return []; + } + return valueString.split(/\s*,\s*/).map(v => v.trim()).filter(v => v.length > 0); +}; + +function parseTargetOption(option: string): { type: string; values: string[] } { + const trimmed = option.trim(); + const spaceIndex = trimmed.indexOf(" "); + if (spaceIndex === -1) { + return { type: trimmed, values: [] }; + } + + const type = trimmed.slice(0, spaceIndex); + const remainder = trimmed.slice(spaceIndex + 1).trim(); + + if (type === "name" || type === "charid") { + return { type, values: splitCommaSeparatedValues(remainder) }; + } + + return { type, values: [] }; +}; + +function generateNameTargets(values: string[]) { + const { playerID } = getPermissions(); + const targets: string[] = []; + const errors: string[] = []; + + for (const name of values) { + const characters = findObjs({ _type: "character", name }, { caseInsensitive: true }); + if (characters.length === 0) { + errors.push(`Character with name "${name}" not found.`); + continue; + } + if (characters.length > 1) { + errors.push(`Multiple characters found with name "${name}". Please use character ID instead.`); + continue; + } + const character = characters[0]; + const characterID = character.id; + const hasPermission = checkPermissionForTarget(playerID, characterID); + if (!hasPermission) { + errors.push(`Permission error. You do not have permission to modify character with name "${name}".`); + continue; + } + targets.push(characterID); + } + + return { + targets, + errors, + }; +}; + +export function generateTargets(message: Roll20ChatMessage, targetOptions: string[]) { + const characterIDs: string[] = []; + const errors: string[] = []; + + for (const option of targetOptions) { + const { type, values } = parseTargetOption(option); + + if (type === "sel" || type === "sel-noparty" || type === "sel-party") { + const results = generateSelectedTargets(message, type); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + + else if (type === "all" || type === "allgm" || type === "allplayers") { + const results = generateAllTargets(type); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + + else if (type === "charid") { + const results = generateCharacterIDTargets(values); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + + else if (type === "name") { + const results = generateNameTargets(values); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + + else if (type === "party") { + const results = generatePartyTargets(); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + } + + const targets = Array.from(new Set(characterIDs)); + + return { + targets, + errors, + }; +}; + diff --git a/ChatSetAttr/src/modules/timer.ts b/ChatSetAttr/src/modules/timer.ts new file mode 100644 index 0000000000..430f95c814 --- /dev/null +++ b/ChatSetAttr/src/modules/timer.ts @@ -0,0 +1,37 @@ +import type { TimerMap } from "../types"; + +const timerMap: TimerMap = new Map(); + +export function startTimer( + key: string, + duration = 50, + callback: () => void +): void { + // Clear any existing timer for the same key + const existingTimer = timerMap.get(key); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + callback(); + timerMap.delete(key); + }, duration); + + timerMap.set(key, timer); +} + +export function clearTimer(key: string): void { + const timer = timerMap.get(key); + if (timer) { + clearTimeout(timer); + timerMap.delete(key); + } +} + +export function clearAllTimers(): void { + for (const timer of timerMap.values()) { + clearTimeout(timer); + } + timerMap.clear(); +} \ No newline at end of file diff --git a/ChatSetAttr/src/modules/updates.ts b/ChatSetAttr/src/modules/updates.ts new file mode 100644 index 0000000000..fad16a3385 --- /dev/null +++ b/ChatSetAttr/src/modules/updates.ts @@ -0,0 +1,234 @@ +import { type AttributeRecord, type AttributeValue, type Command, type ObserverCallbackTarget } from "../types"; +import { getConfig } from "./config"; +import { notifyObservers } from "./observer"; +import { + captureDeletePriorState, + isLegacySheet, + isNewAttributeOrUser, + logicalAttributeKey, + mergeAttributeState, + resolveObserverAddObj, + resolveObserverDestroyObj, + resolveObserverKind, + resolveObserverObj, + toActualName, + toSnapshot, + tryFindLegacyAttribute, +} from "./observerPayload"; + +type UpdateOptions = { + noCreate?: boolean; + priorValues?: Record; + operation?: Command; +}; + +export type UpdateResult = { + errors: string[]; + messages: string[]; + failed: string[]; +}; + +type LogicalGroup = { + target: string; + actualName: string; + keys: string[]; +}; + +export function buildSetAttributeOptions(overrides: { noCreate?: boolean; setWithWorker?: boolean } = {}) { + const { useWorkers = true } = getConfig() || {}; + return { + noCreate: overrides.noCreate ?? false, + setWithWorker: overrides.setWithWorker ?? useWorkers, + }; +}; + +function failureKey(target: string, name: string): string { + return `${target}:${name}`; +}; + +function collectLogicalGroups(results: Record): LogicalGroup[] { + const groups = new Map(); + + for (const target in results) { + for (const name in results[target]) { + const { actualName } = toActualName(name); + const key = logicalAttributeKey(target, actualName); + const existing = groups.get(key); + if (existing) { + existing.keys.push(name); + } else { + groups.set(key, { target, actualName, keys: [name] }); + } + } + } + + return Array.from(groups.values()); +}; + +function groupHasFailure(group: LogicalGroup, failed: Set): boolean { + return group.keys.some(name => failed.has(failureKey(group.target, name))); +}; + +function shouldSkipPairedMaxDelete( + target: string, + actualName: string, + isMax: boolean, + priorValues: Record, + results: Record, +): boolean { + if (!isMax) { + return false; + } + + const maxKey = `${actualName}_max`; + const hasCompanionCurrent = Object.hasOwn(results[target], actualName); + + if (isLegacySheet(target)) { + return hasCompanionCurrent; + } + + // Beacon userAttributes are removed when current is cleared; a follow-up max delete fails. + if (hasCompanionCurrent) { + return true; + } + + if (!hasPriorValue(priorValues[target]?.[maxKey])) { + return true; + } + + return false; +}; + +function hasPriorValue(value: AttributeValue | undefined): boolean { + return value !== undefined && value !== null && value !== ""; +}; + +export async function makeUpdate( + operation: Command, + results: Record, + options?: UpdateOptions +): Promise { + const isSetting = operation !== "delattr"; + const errors: string[] = []; + const messages: string[] = []; + const failed: string[] = []; + const failedSet = new Set(); + + const { noCreate = false, priorValues = {} } = options || {}; + const setOptions = buildSetAttributeOptions({ noCreate }); + const deleteKinds = new Map>>(); + const deleteStates = new Map>>(); + const deleteObserverTargets = new Map(); + + if (!isSetting) { + for (const target in results) { + for (const name in results[target]) { + const { actualName } = toActualName(name); + const groupKey = logicalAttributeKey(target, actualName); + if (!deleteKinds.has(groupKey)) { + deleteKinds.set(groupKey, await resolveObserverKind(target, actualName)); + } + if (!deleteStates.has(groupKey)) { + const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); + deleteStates.set( + groupKey, + await captureDeletePriorState(target, actualName, kind, priorValues), + ); + } + if (!deleteObserverTargets.has(groupKey)) { + const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); + deleteObserverTargets.set( + groupKey, + resolveObserverDestroyObj(target, actualName, kind), + ); + } + } + } + } + + for (const target in results) { + for (const name in results[target]) { + const { actualName, isMax } = toActualName(name); + const type = isMax ? "max" : "current"; + const key = failureKey(target, name); + const newValue = results[target][name]; + + if (isSetting) { + const value = newValue ?? ""; + + try { + const ok = await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); + if (!ok) { + failed.push(key); + failedSet.add(key); + errors.push(`Failed to set attribute '${name}' on target '${target}'.`); + } + } catch (error: unknown) { + failed.push(key); + failedSet.add(key); + errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); + } + + } else { + if (shouldSkipPairedMaxDelete(target, actualName, isMax, priorValues, results)) { + continue; + } + + try { + const ok = await libSmartAttributes.deleteAttribute(target, actualName, type); + if (!ok) { + failed.push(key); + failedSet.add(key); + errors.push(`Failed to delete attribute '${actualName}' on target '${target}'.`); + } + } catch (error: unknown) { + failed.push(key); + failedSet.add(key); + errors.push(`Failed to delete attribute '${actualName}' on target '${target}': ${String(error)}`); + } + + } + } + } + + const groups = collectLogicalGroups(results); + + for (const group of groups) { + if (groupHasFailure(group, failedSet)) { + continue; + } + + const groupKey = logicalAttributeKey(group.target, group.actualName); + const state = isSetting + ? mergeAttributeState(group.target, group.actualName, priorValues, results, false) + : deleteStates.get(groupKey) ?? mergeAttributeState( + group.target, + group.actualName, + priorValues, + results, + true, + ); + const kind = isSetting + ? await resolveObserverKind(group.target, group.actualName) + : deleteKinds.get(logicalAttributeKey(group.target, group.actualName)) ?? await resolveObserverKind(group.target, group.actualName); + + if (isSetting) { + const prev = toSnapshot(group.target, group.actualName, kind, { + current: state.priorCurrent, + max: state.priorMax, + }); + const obj = resolveObserverObj(group.target, group.actualName, kind, state); + + if (isNewAttributeOrUser(kind, state)) { + notifyObservers("add", resolveObserverAddObj(group.target, group.actualName, kind, state)); + } + notifyObservers("change", obj, prev); + } else { + const obj = deleteObserverTargets.get(groupKey) + ?? resolveObserverObj(group.target, group.actualName, kind, state); + notifyObservers("destroy", obj); + } + } + + return { errors, messages, failed }; +}; diff --git a/ChatSetAttr/src/modules/versioning.ts b/ChatSetAttr/src/modules/versioning.ts new file mode 100644 index 0000000000..f9f94713ea --- /dev/null +++ b/ChatSetAttr/src/modules/versioning.ts @@ -0,0 +1,74 @@ +import type { VersionObject } from "../types"; +import { v2_0 } from "../versions/2.0.0"; +import { sendWelcomeMessage } from "./chat"; +import { + getPersistedSchemaVersion, + hasFlag, + persistStateVersionMetadata, + setConfig, + setFlag, +} from "./config"; + +const VERSION_HISTORY: VersionObject[] = [ + v2_0, +]; + +export function welcome() { + const hasWelcomed = hasFlag("welcome"); + if (hasWelcomed) { return; } + + sendWelcomeMessage(); + setFlag("welcome"); +}; + +export function update() { + log("ChatSetAttr: Checking for state schema updates..."); + const currentSchemaVersion = getPersistedSchemaVersion(); + + log(`ChatSetAttr: Current state schema version: ${currentSchemaVersion}`); + checkForUpdates(currentSchemaVersion); + persistStateVersionMetadata(); +}; + +export function checkForUpdates(currentSchemaVersion: number): void { + for (const migration of VERSION_HISTORY) { + log(`ChatSetAttr: Evaluating schema migration to ${migration.version} (appliesTo: ${migration.appliesTo})`); + const applies = migration.appliesTo; + const threshold = Number(applies.replace(/(<=|<|>=|>|=)/, "").trim()); + const comparison = applies.replace(String(threshold), "").trim(); + const compared = compareSchemaVersions(currentSchemaVersion, threshold); + + let shouldApply = false; + switch (comparison) { + case "<=": + shouldApply = compared <= 0; + break; + case "<": + shouldApply = compared < 0; + break; + case ">=": + shouldApply = compared >= 0; + break; + case ">": + shouldApply = compared > 0; + break; + case "=": + shouldApply = compared === 0; + break; + } + + if (shouldApply) { + migration.update(); + currentSchemaVersion = migration.version; + updateVersionInState(currentSchemaVersion); + } + } +} + +function compareSchemaVersions(current: number, threshold: number): number { + return current - threshold; +} + +function updateVersionInState(newSchemaVersion: number): void { + setConfig({ version: newSchemaVersion }); +} diff --git a/ChatSetAttr/src/templates/config.tsx b/ChatSetAttr/src/templates/config.tsx new file mode 100644 index 0000000000..7284902b0f --- /dev/null +++ b/ChatSetAttr/src/templates/config.tsx @@ -0,0 +1,76 @@ +import { getConfig } from "../modules/config"; +import { s } from "../utils/chat"; +import { buttonStyleBase, frameStyleBase, headerStyleBase } from "./styles"; + +const CONFIG_WRAPPER_STYLE = s(frameStyleBase); + +const CONFIG_HEADER_STYLE = s(headerStyleBase); + +const CONFIG_TABLE_STYLE = s({ + width: "100%", + border: "none", + borderCollapse: "separate", + borderSpacing: "0 4px", +}); + +const CONFIG_ROW_STYLE = s({ + marginBottom: "4px", +}); + +const CONFIG_BUTTON_STYLE_ON = s({ + ...buttonStyleBase, + backgroundColor: "#16A34A", + color: "#FFFFFF", + fontWeight: "500", +}); + +const CONFIG_BUTTON_STYLE_OFF = s({ + ...buttonStyleBase, + backgroundColor: "#DC2626", + color: "#FFFFFF", + fontWeight: "500", +}); + +const CONFIG_CLEAR_FIX_STYLE = s({ + clear: "both", +}); + +function camelToKebabCase(str: string): string { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +}; + +export function createConfigMessage(): string { + const config = getConfig(); + const configEntries = Object.entries(config); + const relevantEntries = configEntries.filter(([key]) => + key !== "version" + && key !== "scriptVersion" + && key !== "globalconfigCache" + && key !== "flags" + && key !== "helpContentUpdatedAt" + ); + return ( +
    +
    ChatSetAttr Configuration
    +
    + + {relevantEntries.map(([key, value]) => ( + + + + + ))} +
    + {key}: + + + {value ? "Enabled" : "Disabled"} + +
    +
    +
    +
    + ).html; +}; \ No newline at end of file diff --git a/ChatSetAttr/src/templates/help/contentHash.ts b/ChatSetAttr/src/templates/help/contentHash.ts new file mode 100644 index 0000000000..b67b5b168f --- /dev/null +++ b/ChatSetAttr/src/templates/help/contentHash.ts @@ -0,0 +1,7 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +export function hashHelpContentFile(filePath: string): string { + const bytes = readFileSync(filePath); + return createHash("sha256").update(bytes).digest("hex"); +} diff --git a/ChatSetAttr/src/templates/help/index.ts b/ChatSetAttr/src/templates/help/index.ts new file mode 100644 index 0000000000..2e06394bf5 --- /dev/null +++ b/ChatSetAttr/src/templates/help/index.ts @@ -0,0 +1,6 @@ +import { loadHelpDocument } from "./loadContent"; +import { renderHelpHtml } from "./renderHtml"; + +export function createHelpHandout(handoutID: string): string { + return renderHelpHtml(loadHelpDocument(), handoutID); +} diff --git a/ChatSetAttr/src/templates/help/inlineMarkdown.ts b/ChatSetAttr/src/templates/help/inlineMarkdown.ts new file mode 100644 index 0000000000..4dc67b1922 --- /dev/null +++ b/ChatSetAttr/src/templates/help/inlineMarkdown.ts @@ -0,0 +1,45 @@ +import { SafeHtml } from "../../utils/chat"; + +const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`)/g; + +export function renderInlineMarkdown(text: string): string { + return text; +} + +export function renderInlineHtml(text: string): SafeHtml { + const parts: string[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + INLINE_PATTERN.lastIndex = 0; + while ((match = INLINE_PATTERN.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(escapeHtml(text.slice(lastIndex, match.index))); + } + const token = match[0]; + if (token.startsWith("**")) { + parts.push(`${escapeHtml(token.slice(2, -2))}`); + } else { + parts.push(`${escapeHtml(token.slice(1, -1))}`); + } + lastIndex = match.index + token.length; + } + + if (lastIndex < text.length) { + parts.push(escapeHtml(text.slice(lastIndex))); + } + + return new SafeHtml(parts.join("")); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export function joinCodeLines(lines: string[]): string { + return lines.join("\n"); +} diff --git a/ChatSetAttr/src/templates/help/loadContent.ts b/ChatSetAttr/src/templates/help/loadContent.ts new file mode 100644 index 0000000000..4455354012 --- /dev/null +++ b/ChatSetAttr/src/templates/help/loadContent.ts @@ -0,0 +1,6 @@ +import type { HelpDocument } from "./types"; +import helpContent from "../../../docs/help/content.json"; + +export function loadHelpDocument(): HelpDocument { + return helpContent as HelpDocument; +} diff --git a/ChatSetAttr/src/templates/help/loadContentRevision.ts b/ChatSetAttr/src/templates/help/loadContentRevision.ts new file mode 100644 index 0000000000..31ecfa8e98 --- /dev/null +++ b/ChatSetAttr/src/templates/help/loadContentRevision.ts @@ -0,0 +1,16 @@ +import contentRevision from "../../../docs/help/content.revision.json"; + +export type HelpContentRevision = { + contentHash: string; + updatedAt: number; +}; + +const revision = contentRevision as HelpContentRevision; + +export function getBundledHelpContentUpdatedAt(): number { + return revision.updatedAt; +} + +export function getBundledHelpContentHash(): string { + return revision.contentHash; +} diff --git a/ChatSetAttr/src/templates/help/renderHtml.ts b/ChatSetAttr/src/templates/help/renderHtml.ts new file mode 100644 index 0000000000..3cde0d8c14 --- /dev/null +++ b/ChatSetAttr/src/templates/help/renderHtml.ts @@ -0,0 +1,99 @@ +import { h, SafeHtml } from "../../utils/chat"; +import type { + HelpBlock, + HelpDocument, + HelpSection, + HelpSubsection, +} from "./types"; +import { joinCodeLines, renderInlineHtml } from "./inlineMarkdown"; + +type Child = string | SafeHtml | null | undefined | Child[]; + +function concatHtml(...parts: SafeHtml[]): SafeHtml { + return new SafeHtml(parts.map(part => part.html).join("")); +} + +function renderBlocks(blocks: HelpBlock[] | undefined): SafeHtml[] { + if (!blocks) return []; + + const parts: SafeHtml[] = []; + for (const block of blocks) { + switch (block.type) { + case "paragraph": + parts.push(h("p", {}, renderInlineHtml(block.text))); + break; + case "codeBlock": + parts.push(h("pre", {}, h("code", {}, joinCodeLines(block.lines)))); + break; + case "unorderedList": + parts.push(h( + "ul", + {}, + ...block.items.map(item => h("li", {}, renderInlineHtml(item))), + )); + break; + case "orderedList": + parts.push(h( + "ol", + {}, + ...block.items.map(item => { + const children: Child[] = [renderInlineHtml(item.text)]; + if (item.codeBlock) { + children.push(h("pre", {}, h("code", {}, joinCodeLines(item.codeBlock.lines)))); + } + return h("li", {}, ...children); + }), + )); + break; + case "note": + parts.push(block.emphasis + ? h("p", {}, h("em", {}, h("strong", {}, "Note:"), " ", renderInlineHtml(block.text))) + : h("p", {}, renderInlineHtml(block.text))); + break; + } + } + return parts; +} + +function renderSubsection(subsection: HelpSubsection): SafeHtml { + return concatHtml( + h("h3", {}, subsection.title), + ...renderBlocks(subsection.blocks), + ); +} + +function renderSection(section: HelpSection): SafeHtml { + return concatHtml( + h("h2", { id: section.id }, section.title), + ...renderBlocks(section.blocks), + ...(section.subsections?.map(renderSubsection) ?? []), + ); +} + +function renderTableOfContents(doc: HelpDocument, handoutID: string): SafeHtml { + return h( + "ol", + {}, + ...doc.sections.map(section => h( + "li", + {}, + h( + "a", + { + href: `http://journal.roll20.net/handout/${handoutID}/#${section.title.replace(/\s+/g, "%20")}`, + }, + section.title, + ), + )), + ); +} + +export function renderHelpHtml(doc: HelpDocument, handoutID: string): string { + return concatHtml( + h("h1", {}, doc.title), + h("p", {}, doc.introduction), + h("h2", {}, "Table of Contents"), + renderTableOfContents(doc, handoutID), + ...doc.sections.map(section => renderSection(section)), + ).html; +} diff --git a/ChatSetAttr/src/templates/help/renderMarkdown.ts b/ChatSetAttr/src/templates/help/renderMarkdown.ts new file mode 100644 index 0000000000..b879f8af1f --- /dev/null +++ b/ChatSetAttr/src/templates/help/renderMarkdown.ts @@ -0,0 +1,91 @@ +import type { + HelpBlock, + HelpDocument, + HelpSection, + HelpSubsection, + RenderMarkdownOptions, +} from "./types"; +import { joinCodeLines, renderInlineMarkdown } from "./inlineMarkdown"; + +function slugify(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function renderCodeFence(lines: string[]): string { + return "```\n" + joinCodeLines(lines) + "\n```"; +} + +function renderBlocks(blocks: HelpBlock[] | undefined, lines: string[]): void { + if (!blocks) return; + + for (const block of blocks) { + switch (block.type) { + case "paragraph": + lines.push("", renderInlineMarkdown(block.text)); + break; + case "codeBlock": + lines.push("", renderCodeFence(block.lines)); + break; + case "unorderedList": + lines.push(""); + for (const item of block.items) { + lines.push(`- ${renderInlineMarkdown(item)}`); + } + break; + case "orderedList": + lines.push(""); + block.items.forEach((item, index) => { + lines.push(`${index + 1}. ${renderInlineMarkdown(item.text)}`); + if (item.codeBlock) { + lines.push(renderCodeFence(item.codeBlock.lines)); + } + }); + break; + case "note": + lines.push("", block.emphasis + ? `> **Note:** ${renderInlineMarkdown(block.text)}` + : `> ${renderInlineMarkdown(block.text)}`); + break; + } + } +} + +function renderSubsection(subsection: HelpSubsection, lines: string[]): void { + lines.push("", `### ${subsection.title}`); + renderBlocks(subsection.blocks, lines); +} + +function renderSection(section: HelpSection, lines: string[]): void { + lines.push("", `## ${section.title}`); + renderBlocks(section.blocks, lines); + section.subsections?.forEach(subsection => renderSubsection(subsection, lines)); +} + +export function renderHelpMarkdown( + doc: HelpDocument, + options: RenderMarkdownOptions = {}, +): string { + const lines: string[] = [ + `# ${doc.title}`, + "", + doc.introduction, + ]; + + if (options.includeToc) { + lines.push("", "## Table of Contents", ""); + doc.sections.forEach((section, index) => { + lines.push(`${index + 1}. [${section.title}](#${section.id})`); + }); + } + + doc.sections.forEach(section => renderSection(section, lines)); + + return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"; +} + +export function getSubsectionAnchor(subsection: HelpSubsection): string { + return subsection.id ?? slugify(subsection.title); +} diff --git a/ChatSetAttr/src/templates/help/types.ts b/ChatSetAttr/src/templates/help/types.ts new file mode 100644 index 0000000000..e904004206 --- /dev/null +++ b/ChatSetAttr/src/templates/help/types.ts @@ -0,0 +1,63 @@ +export type CodeBlock = { + lines: string[]; +}; + +export type ParagraphBlock = { + type: "paragraph"; + text: string; +}; + +export type CodeBlockBlock = { + type: "codeBlock"; +} & CodeBlock; + +export type UnorderedListBlock = { + type: "unorderedList"; + items: string[]; +}; + +export type OrderedListItem = { + text: string; + codeBlock?: CodeBlock; +}; + +export type OrderedListBlock = { + type: "orderedList"; + items: OrderedListItem[]; +}; + +export type NoteBlock = { + type: "note"; + text: string; + emphasis?: boolean; +}; + +export type HelpBlock = + | ParagraphBlock + | CodeBlockBlock + | UnorderedListBlock + | OrderedListBlock + | NoteBlock; + +export type HelpSubsection = { + id?: string; + title: string; + blocks: HelpBlock[]; +}; + +export type HelpSection = { + id: string; + title: string; + blocks?: HelpBlock[]; + subsections?: HelpSubsection[]; +}; + +export type HelpDocument = { + title: string; + introduction: string; + sections: HelpSection[]; +}; + +export type RenderMarkdownOptions = { + includeToc?: boolean; +}; diff --git a/ChatSetAttr/src/templates/messages.tsx b/ChatSetAttr/src/templates/messages.tsx new file mode 100644 index 0000000000..7d172a06ef --- /dev/null +++ b/ChatSetAttr/src/templates/messages.tsx @@ -0,0 +1,64 @@ +import { s } from "../utils/chat"; +import { frameStyleBase, frameStyleError, headerStyleBase } from "./styles"; + +const CHAT_WRAPPER_STYLE = s(frameStyleBase); + +const CHAT_HEADER_STYLE = s(headerStyleBase); + +const CHAT_BODY_STYLE = s({ + fontSize: "14px", + lineHeight: "1.4", +}); + +const ERROR_WRAPPER_STYLE = s({ + ...frameStyleBase, + ...frameStyleError, +}); + +const ERROR_HEADER_STYLE = s(headerStyleBase); + +const ERROR_BODY_STYLE = s({ + fontSize: "14px", + lineHeight: "1.4", +}); + +// #region Message Styles Type +type MessageStyles = { + wrapper: string; + header: string; + body: string; +}; + +// #region Generic Message Creation Function +function createMessage( + header: string, + messages: string[], + styles: MessageStyles +): string { + return ( +
    +

    {header}

    +
    + {messages.map(message =>

    {message}

    )} +
    +
    + ).html; +} + +// #region Chat Message Function +export function createChatMessage(header: string, messages: string[]): string { + return createMessage(header, messages, { + wrapper: CHAT_WRAPPER_STYLE, + header: CHAT_HEADER_STYLE, + body: CHAT_BODY_STYLE + }); +} + +// #region Error Message Function +export function createErrorMessage(header: string, errors: string[]): string { + return createMessage(header, errors, { + wrapper: ERROR_WRAPPER_STYLE, + header: ERROR_HEADER_STYLE, + body: ERROR_BODY_STYLE + }); +} \ No newline at end of file diff --git a/ChatSetAttr/src/templates/notice.tsx b/ChatSetAttr/src/templates/notice.tsx new file mode 100644 index 0000000000..ccfc30279c --- /dev/null +++ b/ChatSetAttr/src/templates/notice.tsx @@ -0,0 +1,15 @@ +import { s } from "../utils/chat"; +import { frameStyleNotice, headerStyleBase } from "./styles"; + +const NOTICE_WRAPPER_STYLE = s(frameStyleNotice); + +const NOTICE_HEADER_STYLE = s(headerStyleBase); + +export function createNoticeMessage(title: string, content: string): string { + return ( +
    +
    {title}
    +
    {content}
    +
    + ).html; +} diff --git a/ChatSetAttr/src/templates/notification.tsx b/ChatSetAttr/src/templates/notification.tsx new file mode 100644 index 0000000000..963add71dc --- /dev/null +++ b/ChatSetAttr/src/templates/notification.tsx @@ -0,0 +1,17 @@ +import { rawHtml, s } from "../utils/chat"; +import { frameStyleBase, headerStyleBase } from "./styles"; + +const NOTIFY_WRAPPER_STYLE = s(frameStyleBase); + +const NOTIFY_HEADER_STYLE = s(headerStyleBase); + +export function createNotifyMessage(title: string, content: string): string { + return ( +
    +
    {title}
    +
    + {rawHtml(content)} +
    +
    + ).html; +}; \ No newline at end of file diff --git a/ChatSetAttr/src/templates/styles.ts b/ChatSetAttr/src/templates/styles.ts new file mode 100644 index 0000000000..978a500d95 --- /dev/null +++ b/ChatSetAttr/src/templates/styles.ts @@ -0,0 +1,48 @@ +export const buttonStyleBase = { + border: "none", + borderRadius: "4px", + padding: "4px 8px", + backgroundColor: "rgba(233, 30, 162, 1)", + color: "rgba(255, 255, 255, 1)", + cursor: "pointer", + fontWeight: "500", +}; + +export const buttonStyleSecondary = { + backgroundColor: "rgba(123, 31, 162, 1)", + color: "#FFFFFF", +}; + +export const frameStyleBase = { + border: "1px solid rgba(59, 130, 246, 0.3)", + borderRadius: "8px", + padding: "8px", + backgroundColor: "rgba(59, 130, 246, 0.1)", +}; + +export const frameStyleWarning = { + border: "1px solid rgba(245, 158, 11, 0.4)", + backgroundColor: "rgba(245, 158, 11, 0.1)", +}; + +export const frameStyleNotice = { + border: "1px solid rgba(245, 158, 11, 0.55)", + borderRadius: "8px", + padding: "8px", + backgroundColor: "rgba(245, 158, 11, 0.18)", +}; + +export const frameStyleError = { + border: "1px solid rgba(239, 68, 68, 0.4)", + backgroundColor: "rgba(239, 68, 68, 0.1)", +}; + +export const headerStyleBase = { + fontSize: "1.5em", + marginBottom: "0.5em", +}; + +export const headerStyleSecondary = { + fontSize: "1.25em", + marginBottom: "0.5em", +}; \ No newline at end of file diff --git a/ChatSetAttr/src/templates/versions/2.0.0.tsx b/ChatSetAttr/src/templates/versions/2.0.0.tsx new file mode 100644 index 0000000000..67b3ed93c2 --- /dev/null +++ b/ChatSetAttr/src/templates/versions/2.0.0.tsx @@ -0,0 +1,39 @@ +import { s } from "../../utils/chat"; +import { frameStyleBase } from "../styles"; + +const LI_STYLE = s({ + marginBottom: "4px", +}); + +const WRAPPER_STYLE = s(frameStyleBase); + +const PARAGRAPH_SPACING_STYLE = s({ + marginTop: "8px", + marginBottom: "8px", +}); + +export function createVersionMessage(): string { + return ( +
    +

    ChatSetAttr has been updated to version 2.0!

    +

    This update includes important changes to improve compatibility and performance.

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

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

    +
    + If you encounter any bugs or issues, please report them via the Roll20 Helpdesk +
    +
    + If you want to create a handout with the updated documentation, use the command !setattr-help or click the button below + Create Help Handout +
    +
    + ).html; +} \ No newline at end of file diff --git a/ChatSetAttr/src/templates/welcome.tsx b/ChatSetAttr/src/templates/welcome.tsx new file mode 100644 index 0000000000..69e48b6b43 --- /dev/null +++ b/ChatSetAttr/src/templates/welcome.tsx @@ -0,0 +1,14 @@ +import { s } from "../utils/chat"; +import { buttonStyleBase } from "./styles"; + +export function createWelcomeMessage(): string { + const buttonStyle = s(buttonStyleBase); + return ( +
    +

    Thank you for installing ChatSetAttr.

    +

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

    +

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

    +

    Create Journal Handout

    +
    + ).html; +} \ No newline at end of file diff --git a/ChatSetAttr/src/types.ts b/ChatSetAttr/src/types.ts new file mode 100644 index 0000000000..031dbf0b6b --- /dev/null +++ b/ChatSetAttr/src/types.ts @@ -0,0 +1,203 @@ +// #region Commands + +export const COMMAND_TYPE = [ + "setattr", + "modattr", + "modbattr", + "resetattr", + "delattr" +] as const; + +export type Command = typeof COMMAND_TYPE[number]; + +export function isCommand(command: string): command is Command { + return COMMAND_TYPE.includes(command as Command); +}; + +// #region Command Options + +export const COMMAND_OPTIONS = [ + "mod", + "modb", + "reset" +] as const; + +export type CommandOption = typeof COMMAND_OPTIONS[number]; + +export type OverrideDictionary = Record; + +export const OVERRIDE_DICTIONARY: OverrideDictionary = { + "mod": "modattr", + "modb": "modbattr", + "reset": "resetattr", +} as const; + +export function isCommandOption(option: string): option is CommandOption { + return COMMAND_OPTIONS.includes(option as CommandOption); +}; + +// #region Targets + +export const TARGETS = [ + "all", + "allgm", + "allplayers", + "charid", + "name", + "sel", + "sel-noparty", + "sel-party", + "party", +] as const; + +export type Target = typeof TARGETS[number]; + +export function isTarget(target: string): target is Target { + return TARGETS.includes(target as Target); +}; + +// #region Feedback +export const FEEDBACK_OPTIONS = [ + "fb-public", + "fb-from", + "fb-header", + "fb-content", +] as const; + +export type FeedbackOption = typeof FEEDBACK_OPTIONS[number]; + +export type FeedbackObject = { + public: boolean; + from?: string; + header?: string; + content?: string; +}; + +export function isFeedbackOption(option: string): option is FeedbackOption { + for (const fbOption of FEEDBACK_OPTIONS) { + if (option.startsWith(fbOption)) return true; + } + return false; +}; + +export function extractFeedbackKey(option: string) { + if (option === "fb-public") return "public"; + if (option === "fb-from") return "from"; + if (option === "fb-header") return "header"; + if (option === "fb-content") return "content"; + return false; +}; + +// #region Options +export const OPTIONS = [ + "nocreate", + "evaluate", + "replace", + "silent", + "mute", +] as const; + +export type Option = typeof OPTIONS[number]; + +export type OptionsRecord = Record; + +export function isOption(option: string): option is Option { + return OPTIONS.includes(option as Option); +}; + +// #region Attributes +export type Attribute = { + name?: string; + current?: string | number | boolean; + max?: string | number | boolean; +}; + +export type AttributeValue = string | number | boolean | undefined; + +export type AttributeRecord = Record; + +export type Modification = "increased" | "decreased" | "multiplied" | "divided" | "reset"; + +export type AttributeWithModification = Attribute & { + modification: Modification, + previous: number, + total: number, + current: number +}; + +export type AnyAttribute = Attribute | AttributeWithModification; + +// #region ChangeSet +export type ChangeSetError = { + message: string; + target: string; + attribute?: string; +}; + +export type ChangeSet = { + operation: Command; + targets: string[]; + completed: AnyAttribute[]; + errors: ChangeSetError[]; +}; + +// #region Alias Characters + +export const ALIAS_CHARACTERS: Record = { + "<": "[", + ">": "]", + "~": "-", + ";": "?", + "`": "@", +} as const; + +// #region Versioning + +export type SchemaVersionComparison = + "<=" | + "<" | + ">=" | + ">" | + "=" ; + +export type SchemaVersionAppliesTo = `${SchemaVersionComparison}${number}`; + +export type VersionObject = { + appliesTo: SchemaVersionAppliesTo; + version: number; + update: () => void; +}; + +// #region Observers + +export type ObserverEvent = "add" | "change" | "destroy"; + +export type ObserverAttributeKind = "attribute" | "computed" | "userAttribute"; + +export type ObserverAttributeSnapshot = { + _id: string; + _type: ObserverAttributeKind; + _characterid: string; + name: string; + current: string; + max: string; +}; + +export interface ObserverAttributeObject { + get(key: string): string | undefined; + set(keyOrProps: string | Partial, value?: string): ObserverAttributeObject; + toJSON(): ObserverAttributeSnapshot; +} + +export type ObserverCallbackTarget = Roll20Attribute | ObserverAttributeObject; + +export type ObserverCallback = ( + obj: ObserverCallbackTarget, + prev?: ObserverAttributeSnapshot +) => void; + +export type ObserverRecord = Record; + +// #region Timers + +export type TimerMap = Map>; \ No newline at end of file diff --git a/ChatSetAttr/src/utils/chat.ts b/ChatSetAttr/src/utils/chat.ts new file mode 100644 index 0000000000..8c818217dc --- /dev/null +++ b/ChatSetAttr/src/utils/chat.ts @@ -0,0 +1,57 @@ +// #region Style Helpers +function convertCamelToKebab(camel: string): string { + return camel.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} + +export function s(styleObject: Record = {}) { + let style = ""; + for (const [key, value] of Object.entries(styleObject)) { + const kebabKey = convertCamelToKebab(key); + style += `${kebabKey}: ${value};`; + } + return style; +} + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export class SafeHtml { + constructor(public readonly html: string) {} +} + +export function rawHtml(html: string): SafeHtml { + return new SafeHtml(html); +} + +function renderChild(child: Child): string { + if (child instanceof SafeHtml) { + return child.html; + } + if (typeof child === "string") { + return escapeHtml(child); + } + return ""; +} + +// #region JSX Helper +type Child = string | SafeHtml | null | undefined | Child[]; + +export function h( + tagName: string, + attributes: Record = {}, + ...children: Child[] +): SafeHtml { + const attrs = Object.entries(attributes ?? {}) + .map(([key, value]) => ` ${key}="${escapeHtml(String(value))}"`) + .join(""); + + const flattenedChildren = children.flat(10).filter(child => child != null); + const childrenContent = flattenedChildren.map(renderChild).join(""); + + return new SafeHtml(`<${tagName}${attrs}>${childrenContent}`); +} diff --git a/ChatSetAttr/src/versions/2.0.0.ts b/ChatSetAttr/src/versions/2.0.0.ts new file mode 100644 index 0000000000..7aeb341500 --- /dev/null +++ b/ChatSetAttr/src/versions/2.0.0.ts @@ -0,0 +1,22 @@ +import scriptJson from "../../script.json" assert { type: "json" }; +import { sendNotification } from "../modules/chat"; +import { setConfig } from "../modules/config"; +import { createVersionMessage } from "../templates/versions/2.0.0"; +import type { VersionObject } from "../types"; + +export const v2_0: VersionObject = { + appliesTo: "<=3", + version: 4, + update: () => { + setConfig({ + version: 4, + playersCanTargetParty: true, + scriptVersion: scriptJson.version, + }); + + const title = "ChatSetAttr Updated to Version 2.0"; + const content = createVersionMessage(); + + sendNotification(title, content, false); + }, +}; diff --git a/ChatSetAttr/tsconfig.json b/ChatSetAttr/tsconfig.json new file mode 100644 index 0000000000..668acb0a9c --- /dev/null +++ b/ChatSetAttr/tsconfig.json @@ -0,0 +1,17 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": false, + "resolveJsonModule": true, + "jsx": "react", + "jsxFactory": "h" + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/env.d.ts", "rollup.config.ts"], + "exclude": ["node_modules", "src/__tests__/**", "**/*.test.ts", "**/*.spec.ts", "**/*.mock.ts"] +} diff --git a/ChatSetAttr/tsconfig.script.json b/ChatSetAttr/tsconfig.script.json new file mode 100644 index 0000000000..6ff8d52135 --- /dev/null +++ b/ChatSetAttr/tsconfig.script.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": false, + "resolveJsonModule": true, + "jsx": "react", + "jsxFactory": "h" + }, + "include": ["src", "docs/help/content.json", "docs/help/content.revision.json", "rollup.config.ts"], + "exclude": [ + "node_modules", + "src/__tests__/**", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.mock.ts", + "vitest.setup.ts", + "vitest.config.ts" + ] +} diff --git a/ChatSetAttr/tsconfig.vitest.json b/ChatSetAttr/tsconfig.vitest.json new file mode 100644 index 0000000000..c3f463a910 --- /dev/null +++ b/ChatSetAttr/tsconfig.vitest.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals", "node", "@roll20/api-types"] + }, + "include": [ + "src/env.d.ts", + "docs/help/content.json", + "docs/help/content.revision.json", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.mock.ts", + "vitest.setup.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/ChatSetAttr/vitest.config.ts b/ChatSetAttr/vitest.config.ts new file mode 100644 index 0000000000..7299c47df5 --- /dev/null +++ b/ChatSetAttr/vitest.config.ts @@ -0,0 +1,19 @@ +/// +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + typecheck: { + tsconfig: "./tsconfig.vitest.json" + }, + setupFiles: ["./vitest.setup.ts"], + coverage: { + provider: "v8", + reporter: ["text", "html"], + include: ["src/**/*.ts"], + exclude: ["src/__tests__/**"], + }, + }, +}); \ No newline at end of file diff --git a/ChatSetAttr/vitest.setup.ts b/ChatSetAttr/vitest.setup.ts new file mode 100644 index 0000000000..a702ac76d1 --- /dev/null +++ b/ChatSetAttr/vitest.setup.ts @@ -0,0 +1,73 @@ +import { vi } from "vitest"; + +import { default as SA } from "lib-smart-attributes"; +import { default as underscore } from "underscore"; + +import { mockedOn, simulateChatMessage, mockTriggerEvent } from "./src/__mocks__/eventHandling.mock"; +import { mockCreateObj, mockFindObjs, mockGetAllObjs, mockGetAttrByName, mockGetObj, mockCampaign } from "./src/__mocks__/apiObjects.mock"; +import { getSheetItem, setSheetItem } from "./src/__mocks__/beaconAttributes.mock"; +import { log } from "./src/__mocks__/utility.mock"; +import { h, s } from "./src/utils/chat"; + +// region Global Declarations +declare global { + var executeCommand: typeof simulateChatMessage; + var triggerEvent: typeof mockTriggerEvent; + var _: typeof underscore; +}; + +// region Libraries +global._ = underscore; + +// region Logging +global.log = log; + +// region Event Handling +global.on = mockedOn; +global.triggerEvent = mockTriggerEvent; +global.executeCommand = simulateChatMessage; + +// region State +global.state = { + ChatSetAttr: { + version: 4, + scriptVersion: "2.0", + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: true + } +}; + +// region Global Config +global.globalconfig = {}; + +// region Objects +global.getObj = vi.fn(mockGetObj); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +global.findObjs = vi.fn(mockFindObjs) as any; +global.createObj = vi.fn(mockCreateObj); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +global.getAllObjs = vi.fn(mockGetAllObjs) as any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +global.getAttrByName = vi.fn(mockGetAttrByName) as any; + +// region Beacon Attributes +global.getSheetItem = getSheetItem; +global.setSheetItem = setSheetItem; + +// region Utility Functions +global.playerIsGM = vi.fn(); +global.sendChat = vi.fn(); +global.Campaign = mockCampaign({ computedSummary: {} }); + +// region Requirements +global.libSmartAttributes = SA; +global.libUUID = { + generateRowID: vi.fn(() => "unique-rowid-1234"), + generateUUID: vi.fn(() => "unique-libUUID-5678"), +}; + +// region JSX Helpers +global.h = h; +global.s = s; +// endregion \ No newline at end of file diff --git a/libSmartAttributes/0.0.4/libSmartAttributes.js b/libSmartAttributes/0.0.4/libSmartAttributes.js new file mode 100644 index 0000000000..a05379fb7c --- /dev/null +++ b/libSmartAttributes/0.0.4/libSmartAttributes.js @@ -0,0 +1,95 @@ +// libSmartAttributes v0.0.4 by GUD Team | libSmartAttributes provides an interface for managing beacon attributes in a slightly smarter way. +var libSmartAttributes = (function () { + 'use strict'; + + async function getAttribute(characterId, name, type = "current") { + // Try for a legacy attribute or beacon computed + const attr = await getSheetItem(characterId, name, type); + if (attr !== null && attr !== undefined) { + return attr; + } + // Then try for the user attribute + const userAttr = await getSheetItem(characterId, `user.${name}`, type); + if (userAttr !== null && userAttr !== undefined) { + return userAttr; + } + log(`Attribute ${name} not found on character ${characterId}`); + return undefined; + } + async function setAttribute(characterId, name, value, type = "current", options) { + try { + await setSheetItem(characterId, name, value, type, { + allowThrow: true, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); + return true; + } + catch (e) { + // throw will happen on beacon sheets if the computed doesn't exist or is read-only + switch (e.type) { + // for read only computeds, we don't want to make a shadow "user." version. + case "COMPUTED_READONLY": + return false; + } + } + // Then default to a user attribute + try { + await setSheetItem(characterId, `user.${name}`, value, type, { + allowThrow: true, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); + return true; + } + catch { + return false; + } + } + async function deleteAttribute(characterId, name, type = "current") { + const character = getObj("character", characterId); + if (!character) { + return false; + } + if (character?.sheetEnvironment === "legacy" || character?.sheetEnvironment === undefined) { + const legacyAttr = findObjs({ + _type: "attribute", + _characterid: characterId, + name: name, + })[0]; + if (legacyAttr) { + legacyAttr.remove(); + return true; + } + return false; + } + // Beacon computeds cannot be deleted (no change to the computed value). + const beaconAttr = await getSheetItem(characterId, name, type); + if (beaconAttr !== null && beaconAttr !== undefined) { + return false; + } + // Then try for the user attribute + const userAttr = await getSheetItem(characterId, `user.${name}`, type); + if (userAttr !== null && userAttr !== undefined) { + try { + await setSheetItem(characterId, `user.${name}`, undefined, type, { + allowThrow: true, + createAttr: false + }); + return true; + } + catch { + return false; + } + } + return false; + } + var index = { + getAttribute, + setAttribute, + deleteAttribute, + }; + + return index; + +})(); diff --git a/libSmartAttributes/script.json b/libSmartAttributes/script.json index 9e156eeb23..66de2c135b 100644 --- a/libSmartAttributes/script.json +++ b/libSmartAttributes/script.json @@ -1,6 +1,6 @@ { "name": "libSmartAttributes", - "version": "0.0.3", + "version": "0.0.4", "description": "libSmartAttributes provides an interface for managing beacon attributes in a slightly smarter way.", "authors": "GUD Team", "roll20userid": "8705027", @@ -10,6 +10,7 @@ "script": "libSmartAttributes.js", "useroptions": [], "previousversions": [ + "0.0.3", "0.0.2", "0.0.1" ] diff --git a/libSmartAttributes/src/index.ts b/libSmartAttributes/src/index.ts index 37bcefc170..f178375765 100644 --- a/libSmartAttributes/src/index.ts +++ b/libSmartAttributes/src/index.ts @@ -22,9 +22,16 @@ async function getAttribute( }; type SetOptions = { + setWithWorker?: boolean; noCreate?: boolean; }; +type SheetItemError = Error & { + type: string; + details?: Record; +}; + + async function setAttribute( characterId: string, name: string, @@ -34,54 +41,75 @@ async function setAttribute( ) { try { - await setSheetItem(characterId, name, value, type, {allowThrow: true}); - return; - } catch { + await setSheetItem(characterId, name, value, type, { + allowThrow: true, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); + return true; + } catch (e) { // throw will happen on beacon sheets if the computed doesn't exist or is read-only - } - - // Guard against creating user attributes if noCreate is set - if (options?.noCreate) { - log(`Attribute ${name} not found on character ${characterId}, and noCreate option is set. Skipping creation.`); - return; + switch((e as SheetItemError).type){ + // for read only computeds, we don't want to make a shadow "user." version. + case "COMPUTED_READONLY": + return false; + } } // Then default to a user attribute - setSheetItem(characterId, `user.${name}`, value, type); - return; + try { + await setSheetItem(characterId, `user.${name}`, value, type, { + allowThrow: true, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); + return true; + } catch { + return false; + } }; async function deleteAttribute(characterId: string, name: string, type: AttributeType = "current") { - // Try for legacy attribute first - const legacyAttr = findObjs({ - _type: "attribute", - _characterid: characterId, - name: name, - })[0]; - - if (legacyAttr) { - legacyAttr.remove(); - return; + const character = getObj("character",characterId); + if(!character) { + return false; + } + + if (character?.sheetEnvironment === "legacy" || character?.sheetEnvironment === undefined) { + const legacyAttr = findObjs({ + _type: "attribute", + _characterid: characterId, + name: name, + })[0]; + + if (legacyAttr) { + legacyAttr.remove(); + return true; + } + return false; } - // Then try for the beacon computed + // Beacon computeds cannot be deleted (no change to the computed value). const beaconAttr = await getSheetItem(characterId, name, type); if (beaconAttr !== null && beaconAttr !== undefined) { - log(`Cannot delete beacon computed attribute ${name} on character ${characterId}. Setting to undefined instead`); - setSheetItem(characterId, name, undefined, type); - return; + return false; } // Then try for the user attribute const userAttr = await getSheetItem(characterId, `user.${name}`, type); if (userAttr !== null && userAttr !== undefined) { - log(`Deleting user attribute ${name} on character ${characterId}`); - setSheetItem(characterId, `user.${name}`, undefined, type); - return; + try { + await setSheetItem(characterId, `user.${name}`, undefined, type, { + allowThrow: true, + createAttr: false + }); + return true; + } catch { + return false; + } } - log(`Attribute ${type} not found on character ${characterId}, nothing to delete`); - return; + return false; }; export default { diff --git a/libSmartAttributes/src/types.d.ts b/libSmartAttributes/src/types.d.ts index b881d3d2ae..d76b339e20 100644 --- a/libSmartAttributes/src/types.d.ts +++ b/libSmartAttributes/src/types.d.ts @@ -1,5 +1,5 @@ declare namespace SmartAttributes { function getAttribute(characterId: string, name: string, type?: "current" | "max"): Promise; - function setAttribute(characterId: string, name: string, value: unknown, type?: "current" | "max", options?: { setWithWorker?: boolean, noCreate?: boolean }): Promise; - function deleteAttribute(characterId: string, name: string, type?: "current" | "max", options?: { setWithWorker?: boolean }): Promise; + function setAttribute(characterId: string, name: string, value: unknown, type?: "current" | "max", options?: { setWithWorker?: boolean, noCreate?: boolean }): Promise; + function deleteAttribute(characterId: string, name: string, type?: "current" | "max"): Promise; } \ No newline at end of file diff --git a/libSmartAttributes/tests/index.test.ts b/libSmartAttributes/tests/index.test.ts index b3f2e5fc81..1d94b2c2c7 100644 --- a/libSmartAttributes/tests/index.test.ts +++ b/libSmartAttributes/tests/index.test.ts @@ -1,20 +1,42 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import SmartAttributes from "../src/index"; -// Mock Roll20 API functions +const mockFindObjs = vi.fn(); +const mockGetObj = vi.fn(); const mockGetSheetItem = vi.fn(); const mockSetSheetItem = vi.fn(); const mockLog = vi.fn(); - -// Setup global mocks +vi.stubGlobal("findObjs", mockFindObjs); +vi.stubGlobal("getObj", mockGetObj); vi.stubGlobal("getSheetItem", mockGetSheetItem); vi.stubGlobal("setSheetItem", mockSetSheetItem); vi.stubGlobal("log", mockLog); +/** Matches default setSheetItem options from setAttribute */ +const sheetOpts = (overrides: { + allowThrow?: boolean; + createAttr?: boolean; + withWorker?: boolean; +} = {}) => ({ + allowThrow: true, + createAttr: true, + withWorker: true, + ...overrides, +}); + +/** Mimics setSheetItem errors from displayErrorMessage(..., true, errorType, details) */ +const sheetItemError = (type: string, message = "setSheetItem failed") => { + const err = new Error(message) as Error & { type: string; details?: Record }; + err.type = type; + return err; +}; + describe("SmartAttributes", () => { beforeEach(() => { vi.clearAllMocks(); + mockFindObjs.mockReturnValue([]); + mockGetObj.mockReturnValue({ sheetEnvironment: "beacon" }); }); describe("getAttribute", () => { @@ -50,21 +72,21 @@ describe("SmartAttributes", () => { }); it("should handle falsy beacon values correctly", async () => { - mockGetSheetItem.mockResolvedValueOnce(0); // 0 is now treated as valid + mockGetSheetItem.mockResolvedValueOnce(0); const result = await SmartAttributes.getAttribute(characterId, attributeName); - expect(result).toBe(0); // 0 is returned as valid beacon value + expect(result).toBe(0); expect(mockGetSheetItem).toHaveBeenCalledTimes(1); expect(mockGetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "current"); }); it("should handle empty string beacon values correctly", async () => { - mockGetSheetItem.mockResolvedValueOnce(""); // '' is now treated as valid + mockGetSheetItem.mockResolvedValueOnce(""); const result = await SmartAttributes.getAttribute(characterId, attributeName); - expect(result).toBe(""); // Empty string is returned as valid beacon value + expect(result).toBe(""); expect(mockGetSheetItem).toHaveBeenCalledTimes(1); expect(mockGetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "current"); }); @@ -75,62 +97,290 @@ describe("SmartAttributes", () => { const attributeName = "strength"; const value = "18"; - it("should set beacon computed attribute when no legacy attribute but beacon exists", async () => { + it("should return true when setSheetItem succeeds on computed", async () => { mockSetSheetItem.mockResolvedValue("updated-value"); const result = await SmartAttributes.setAttribute(characterId, attributeName, value); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, value, "current", {allowThrow: true}); - expect(result).toBeUndefined(); + expect(mockSetSheetItem).toHaveBeenCalledTimes(1); + expect(mockSetSheetItem).toHaveBeenCalledWith( + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(result).toBe(true); }); - it("should default to user attribute when no legacy or beacon attribute exists", async () => { + it("should return true when falling through to user attribute", async () => { mockSetSheetItem - .mockImplementationOnce(()=>{throw new Error("missing computed");}) + .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue("user-value"); const result = await SmartAttributes.setAttribute(characterId, attributeName, value); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, `user.${attributeName}`, value, "current"); - expect(result).toBeUndefined(); + expect(mockSetSheetItem).toHaveBeenCalledTimes(2); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 1, + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + value, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(result).toBe(true); + }); + + it("should return false and not create user attribute when computed is read-only", async () => { + mockSetSheetItem.mockRejectedValueOnce( + sheetItemError("COMPUTED_READONLY", 'ERROR: Readonly Property "strength".') + ); + + const result = await SmartAttributes.setAttribute(characterId, attributeName, value); + + expect(mockSetSheetItem).toHaveBeenCalledTimes(1); + expect(mockSetSheetItem).toHaveBeenCalledWith( + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(result).toBe(false); + }); + + it("should return true when falling through for non-readonly setSheetItem errors", async () => { + mockSetSheetItem + .mockRejectedValueOnce( + sheetItemError("COMPUTED_INVALID", 'ERROR: Property "strength" doesn\'t exist.') + ) + .mockResolvedValue("user-value"); + + const result = await SmartAttributes.setAttribute(characterId, attributeName, value); + + expect(mockSetSheetItem).toHaveBeenCalledTimes(2); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + value, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(result).toBe(true); + }); + + it("should return false when user attribute fallback also fails", async () => { + mockSetSheetItem + .mockRejectedValueOnce(new Error("missing computed")) + .mockRejectedValueOnce(new Error("user set failed")); + + const result = await SmartAttributes.setAttribute(characterId, attributeName, value); + + expect(mockSetSheetItem).toHaveBeenCalledTimes(2); + expect(result).toBe(false); }); - it("should handle complex values correctly", async () => { + it("should pass createAttr false when noCreate is set", async () => { + mockSetSheetItem + .mockRejectedValueOnce(new Error("missing computed")) + .mockResolvedValue("user-value"); + + const result = await SmartAttributes.setAttribute(characterId, attributeName, value, "current", { + noCreate: true, + }); + + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 1, + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true, createAttr: false }) + ); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + value, + "current", + sheetOpts({ allowThrow: true, createAttr: false }) + ); + expect(result).toBe(true); + }); + + it("should pass withWorker false when setWithWorker is false", async () => { + mockSetSheetItem.mockResolvedValue("ok"); + + const result = await SmartAttributes.setAttribute(characterId, attributeName, value, "current", { + setWithWorker: false, + }); + + expect(mockSetSheetItem).toHaveBeenCalledWith( + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true, withWorker: false }) + ); + expect(result).toBe(true); + }); + + it("should return true for complex values via user fallback", async () => { const complexValue = { nested: { value: 42 } }; mockSetSheetItem - .mockImplementationOnce(()=>{throw new Error("missing computed");}) + .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue(complexValue); const result = await SmartAttributes.setAttribute(characterId, attributeName, complexValue); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, complexValue, "current", {allowThrow:true}); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, `user.${attributeName}`, complexValue, "current"); - expect(result).toBeUndefined(); + expect(result).toBe(true); }); - it("should handle null and undefined values", async () => { - mockSetSheetItem.mockResolvedValue(null); + it("should return true when setting null via user fallback", async () => { mockSetSheetItem - .mockImplementationOnce(()=>{throw new Error("missing computed");}) + .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue(null); const result = await SmartAttributes.setAttribute(characterId, attributeName, null); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, null, "current",{allowThrow:true}); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, `user.${attributeName}`, null, "current"); - expect(result).toBeUndefined(); + expect(result).toBe(true); }); + }); - it("should handle falsy beacon values correctly for setting", async () => { - mockGetSheetItem.mockResolvedValue(0); // 0 is now treated as valid existing beacon value - mockSetSheetItem.mockResolvedValue("updated"); + describe("deleteAttribute", () => { + const characterId = "char123"; + const attributeName = "strength"; - const result = await SmartAttributes.setAttribute(characterId, attributeName, value); + it("should return true when removing a legacy attribute", async () => { + const mockRemove = vi.fn(); + mockGetObj.mockReturnValue({ sheetEnvironment: "legacy" }); + mockFindObjs.mockReturnValue([{ remove: mockRemove }]); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockFindObjs).toHaveBeenCalledWith({ + _type: "attribute", + _characterid: characterId, + name: attributeName, + }); + expect(mockRemove).toHaveBeenCalled(); + expect(mockGetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, value,"current",{allowThrow:true}); - expect(result).toBeUndefined(); + it("should return true when removing a legacy attribute on default-sandbox characters", async () => { + const mockRemove = vi.fn(); + mockGetObj.mockReturnValue({}); + mockFindObjs.mockReturnValue([{ remove: mockRemove }]); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockRemove).toHaveBeenCalled(); + expect(mockGetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should return false when legacy character has no matching attribute", async () => { + mockGetObj.mockReturnValue({ sheetEnvironment: "legacy" }); + mockFindObjs.mockReturnValue([]); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockGetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should return false when character is not found", async () => { + mockGetObj.mockReturnValue(null); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(result).toBe(false); + }); + + it("should return false when a beacon computed exists and no legacy attribute exists", async () => { + mockGetSheetItem.mockResolvedValueOnce("10"); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockFindObjs).toHaveBeenCalledTimes(0); + expect(mockGetSheetItem).toHaveBeenCalledTimes(1); + expect(mockGetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "current"); + expect(mockSetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should return false for falsy beacon computed values without calling setSheetItem", async () => { + mockGetSheetItem.mockResolvedValueOnce(0); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockSetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should ignore legacy attribute on beacon character before checking computed", async () => { + const mockRemove = vi.fn(); + mockGetObj.mockReturnValue({ sheetEnvironment: "beacon" }); + mockFindObjs.mockReturnValue([{ remove: mockRemove }]); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockRemove).not.toHaveBeenCalled(); + expect(mockGetSheetItem).toHaveBeenCalledTimes(2); + expect(result).toBe(false); + }); + + it("should return true when deleting an existing user attribute", async () => { + mockGetSheetItem + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("user-value"); + mockSetSheetItem.mockResolvedValue(true); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockGetSheetItem).toHaveBeenNthCalledWith(1, characterId, attributeName, "current"); + expect(mockGetSheetItem).toHaveBeenNthCalledWith(2, characterId, `user.${attributeName}`, "current"); + expect(mockSetSheetItem).toHaveBeenCalledWith( + characterId, + `user.${attributeName}`, + undefined, + "current", + { allowThrow: true, createAttr: false } + ); + expect(result).toBe(true); + }); + + it("should return false when no attribute exists", async () => { + mockGetSheetItem.mockResolvedValue(null); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockSetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should return false when user attribute delete fails", async () => { + mockGetSheetItem + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("user-value"); + mockSetSheetItem.mockRejectedValueOnce(new Error("delete failed")); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(result).toBe(false); }); }); @@ -149,7 +399,7 @@ describe("SmartAttributes", () => { }); it("should handle boolean values in attributes", async () => { - mockGetSheetItem.mockResolvedValueOnce(false); // Test with false to show falsy values are valid + mockGetSheetItem.mockResolvedValueOnce(false); const result = await SmartAttributes.getAttribute(characterId, attributeName); @@ -165,32 +415,42 @@ describe("SmartAttributes", () => { mockGetSheetItem.mockResolvedValue("beacon-10"); mockSetSheetItem.mockResolvedValue("beacon-15"); - // Get current value const currentValue = await SmartAttributes.getAttribute(characterId, attributeName); expect(currentValue).toBe("beacon-10"); - // Set new value const result = await SmartAttributes.setAttribute(characterId, attributeName, "beacon-15"); - expect(result).toBeUndefined(); + expect(result).toBe(true); }); - it("should handle get returning undefined but set still working", async () => { + it("should return true when set falls through to user attribute", async () => { mockGetSheetItem.mockResolvedValue(null); mockSetSheetItem - .mockImplementationOnce(()=>{throw new Error("missing computed");}) + .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue("new-value"); - // Get returns undefined const currentValue = await SmartAttributes.getAttribute(characterId, attributeName); expect(currentValue).toBeUndefined(); - // But set still works by creating user attribute const result = await SmartAttributes.setAttribute(characterId, attributeName, "new-value"); - expect(result).toBeUndefined(); + expect(result).toBe(true); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "new-value", "current",{allowThrow:true}); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, `user.${attributeName}`, "new-value", "current"); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 1, + characterId, + attributeName, + "new-value", + "current", + sheetOpts({ allowThrow: true }) + ); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + "new-value", + "current", + sheetOpts({ allowThrow: true }) + ); }); }); });