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..edb3a5a0e7 --- /dev/null +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -0,0 +1,3750 @@ +// 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: "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 = 1781657828941; + 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; + } + // #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) { + 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 = 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..edb3a5a0e7 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -1,785 +1,3750 @@ -// 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: "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 = 1781657828941; + 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; + } + // #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) { + 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 = 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..bcf2560649 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/message.test.ts @@ -0,0 +1,724 @@ +import { describe, it, expect } from "vitest"; +import { + extractMessageFromRollTemplate, + 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"); + }); + }); + }); + }); +}); \ 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..548eaa5cdd --- /dev/null +++ b/ChatSetAttr/src/modules/main.ts @@ -0,0 +1,283 @@ +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, 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 = 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..99391f9f33 --- /dev/null +++ b/ChatSetAttr/src/modules/message.ts @@ -0,0 +1,157 @@ +import { + COMMAND_TYPE, + extractFeedbackKey, + isCommand, + isCommandOption, + isFeedbackOption, + isOption, + OVERRIDE_DICTIONARY, + TARGETS, + type Attribute, + type Command, + type FeedbackObject, + type OptionsRecord +} from "../types"; +import { cleanValue } from "./helpers"; + +// #region Inline Message Extraction and Validation +export function validateMessage(content: string): boolean { + for (const command of COMMAND_TYPE) { + const messageCommand = content.split(" ")[0]; + if (messageCommand === `!${command}`) { + return true; + } + } + return false; +}; + +export function extractMessageFromRollTemplate(msg: Roll20ChatMessage): string | false { + for (const command of COMMAND_TYPE) { + if (msg.content.includes(command)) { + const regex = new RegExp(`(!${command}.*?)!!!`, "gi"); + const match = regex.exec(msg.content); + if (match) return match[1].trim(); + } + } + return false; +}; + +// #region Message Parsing +function extractOperation(parts: string[]): Command | 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) { + 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 }) + ); }); }); });