var HealthMonitor = (() => { const version = "1.0.0"; //Changelog //1.0.0 New script from Twilight Sanctuary const STATE_KEY = 'HealthMonitor'; /* ------------------------------------ */ /* CAMPAIGN MARKER CACHE */ /* ------------------------------------ */ // Parsed once on ready and never again — token_markers does not change mid-game. let _campaignMarkersByTag = null; const getCampaignMarkersByTag = () => { if (_campaignMarkersByTag) return _campaignMarkersByTag; try { const arr = JSON.parse(Campaign().get("token_markers") || "[]"); _campaignMarkersByTag = {}; arr.forEach(m => { _campaignMarkersByTag[m.tag] = m; }); } catch (e) { _campaignMarkersByTag = {}; } return _campaignMarkersByTag; }; /* ------------------------------------ */ /* CENTRAL CSS (NO FLEX ALLOWED) */ /* ------------------------------------ */ const CSS = { wrap: "background:#141628; padding:10px; border:1px solid #8a8fff; border-radius:10px; color:#d7dcff; font-family:Arial;", title: "text-align:center; font-size:16px; font-weight:bold; color:#cfd6ff; text-shadow:0 0 6px #5a6bff;", // titleLink: applied to tags used as the clickable header. // Explicitly resets every browser/Roll20 default that would override the title appearance. titleLink: "display:block; text-align:center; font-size:16px; font-weight:bold; color:#cfd6ff; text-shadow:0 0 6px #5a6bff; text-decoration:none; background:none; border:none; cursor:pointer; padding:0; margin:0;", table: "width:100%; border-collapse:collapse;", row: "border-bottom:1px solid #5a6bff50;", cell: "padding:4px; vertical-align:middle;", img: "max-width:35px; max-height:35px; border-radius:6px; border:1px solid #8a8fff;", button: "background:#5a6bc8; color:#e6e9ff; border:1px solid #8a8fff; border-radius:6px; padding:0px 2px; margin-right:4px; font-size:12px; font-weight:bold;", // values: background:none ensures Roll20 chat does not inject a link background on tags. values: "display:inline-block; background:none; color:#e6e9ff; border:1px solid #8a8fff; border-radius:6px; padding:0px 2px; margin-right:6px; font-size:12px; font-weight:bold;", danger: "background:#a84a4a; border:1px solid #ff7a7a; color:#ffd6d6;", notice: "text-align:center; padding:2px; color:#d7dcff;", footer: "text-align:center; margin-top:8px;", divider: "border:none; border-top:1px solid #5a6bff; margin:8px 0;", section: "font-size:11px; font-weight:bold; color:#a0aaff; text-align:center; margin:4px 0 2px 0;" }; /* ------------------------------------ */ /* STATE */ /* ------------------------------------ */ const getState = () => { state[STATE_KEY] = state[STATE_KEY] || { playerAssignments: {}, clerics: {}, disabledMarkers: {}, showHealth: true, showBloodied: true, showTempHP: true }; return state[STATE_KEY]; }; /* * State shape: * st.showHealth = boolean — show current HP in the party frame * st.showBloodied = boolean — colour HP red when at/below half max; ❤️ in single digits * st.showTempHP = boolean — show temporary HP in the party frame * st.clerics[clericId] = { * image: string, token imgsrc * tracked: { [charId]: { image: string } } * mode: "health" | "twilight" * isTwilight: boolean (override or auto-detected) * levelOverride: number|null * } * st.disabledMarkers = { [tag]: true } * Tags present in this object are excluded from the party frame display. * All markers are enabled by default (absence = enabled). */ /* ------------------------------------ */ /* NOTE: getSheetItem / setSheetItem */ /* work on both the Default and */ /* Experimental API servers. Roll20 */ /* falls back to legacy attribute funcs */ /* automatically on the Default server. */ /* ------------------------------------ */ /* ------------------------------------ */ /* SAFE SHEET ACCESS */ /* ------------------------------------ */ const safeGetSheetItem = async (charId, attr, prop) => { try { const val = prop ? await getSheetItem(charId, attr, prop) : await getSheetItem(charId, attr); return (val !== undefined && val !== null) ? String(val) : "0"; } catch (e) { return "0"; } }; /* ------------------------------------ */ /* UTIL */ /* ------------------------------------ */ const whisper = (playerid, msg) => { const player = getObj("player", playerid); if (!player) return; const content = (typeof msg === "string" && !/^\s*
" + sanitize(msg) + "
" : msg; sendChat("HealthMonitor", "/w \"" + player.get("_displayname") + "\" " + content, null, { noarchive: true }); }; const notifyChange = (playerid, char, field, oldVal, newVal) => { const player = getObj("player", playerid); if (!player) return; const name = char ? sanitize(char.get("name")) : "Unknown"; const label = field === "hp" ? "hit points" : "temporary hit points"; const html = "
" + "
" + "" + name + " " + label + ": " + oldVal + " \u27A1 " + newVal + "" + "
" + "
"; sendChat("HealthMonitor", "/w \"" + player.get("_displayname") + "\" " + html, null, { noarchive: true }); if (char) { (char.get("controlledby") || "").split(",").forEach(id => { id = id.trim(); if (!id || id === "all" || id === playerid) return; whisper(id, html); }); } }; const getSelected = (msg) => { if (!msg.selected || !msg.selected.length) return null; return getObj("graphic", msg.selected[0]._id); }; const getChar = (token) => { if (!token) return null; const charId = token.get("represents"); if (!charId) return null; return getObj("character", charId); }; const getSheetType = (char) => { if (!char) return "2014"; return (char.get("_charactersheetname") || "") === "dnd2024byroll20" ? "2024" : "2014"; }; const sanitize = (str) => (str || "").replace(/"/g, """); const rollD6 = () => 1 + Math.floor(Math.random() * 6); const buildButton = (label, cmd) => "
" + label + ""; /* ------------------------------------ */ /* CLERIC LEVEL RESOLUTION */ /* ------------------------------------ */ const getTwilightClericLevel = async (char, clericData) => { if (!char) return 0; if (clericData && clericData.levelOverride !== null && clericData.levelOverride !== undefined) { return parseInt(clericData.levelOverride, 10) || 0; } if (getSheetType(char) === "2024") { const level = await safeGetSheetItem(char.id, "base_level"); return level ? parseInt(level, 10) : 0; } const classDisplay = await safeGetSheetItem(char.id, "class_display") || ""; const match = classDisplay.match(/Twilight Domain Cleric\s+(\d{1,2})/i); return match ? parseInt(match[1], 10) : 0; }; /* Detect whether the cleric is a Twilight Domain Cleric (auto or override) */ const isTwilightCleric = async (char, clericData) => { if (clericData && clericData.isTwilight !== undefined) return !!clericData.isTwilight; if (!char) return false; if (getSheetType(char) === "2024") return false; // cannot auto-detect on 2024 const classDisplay = await safeGetSheetItem(char.id, "class_display") || ""; return /Twilight Domain/i.test(classDisplay); }; /* ------------------------------------ */ /* PAGE RESOLUTION */ /* ------------------------------------ */ // Returns the page id for a given player, accounting for GM last-page, // player-specific pages, and the campaign player page fallback. const getPageForPlayer = (playerid) => { const player = getObj("player", playerid); if (playerIsGM(playerid)) { return player.get("lastpage") || Campaign().get("playerpageid"); } const psp = Campaign().get("playerspecificpages"); if (psp && psp[playerid]) return psp[playerid]; return Campaign().get("playerpageid"); }; /* ------------------------------------ */ /* AURA HELPER */ /* ------------------------------------ */ const setAura = (clericId, playerid, enable) => { // Use page-aware token lookup so we toggle the aura on the correct page const playerPageId = getPageForPlayer(playerid); const token = filterObjs(o => o.get("_type") === "graphic" && o.get("represents") === clericId && o.get("_pageid") === playerPageId )[0] || filterObjs(o => o.get("_type") === "graphic" && o.get("represents") === clericId )[0]; if (!token) return; // graceful bypass if no token found if (enable) { let color = token.get("aura1_color"); if (!color || color === "") color = "rgba(110,120,255,0.15)"; token.set({ aura1_radius: 30, aura1_color: color, showplayers_aura1: true }); } else { token.set({ aura1_radius: "", showplayers_aura1: false }); } }; /* ------------------------------------ */ /* TOKEN MARKER DISPLAY */ /* ------------------------------------ */ /** * buildMarkerHtml(token) → string * * token.get("statusmarkers") is a comma-separated string of tag values, * e.g. "Bloodied::30981, sheet-unconscious::31039". * * Campaign().get("token_markers") is the master lookup table — a stringified * JSON array of { id, name, tag, url } for every marker in the campaign. * * We parse the token's tag list, look each one up in the campaign table, * and render a small inline image with the marker name as hover text. */ const buildMarkerHtml = (token) => { if (!token) return ""; const raw = (token.get("statusmarkers") || "").trim(); if (!raw) return ""; const markerByTag = getCampaignMarkersByTag(); const disabledMarkers = getState().disabledMarkers || {}; return raw.split(",").map(t => t.trim()).filter(Boolean).map(tag => { if (disabledMarkers[tag]) return ""; // excluded by user config const m = markerByTag[tag]; if (!m || !m.url) return ""; return ""; }).join(""); }; /* ------------------------------------ */ /* CORE: MENU */ /* ------------------------------------ */ const renderMenu = async (playerid) => { const st = getState(); const clericId = st.playerAssignments[playerid]; if (!clericId) { whisper(playerid, buildNoClericMenu()); return; } const cleric = getObj("character", clericId); if (!cleric) { delete st.playerAssignments[playerid]; whisper(playerid, buildNoClericMenu()); return; } const clericData = st.clerics[clericId] || { tracked: {}, mode: "health", isTwilight: undefined, levelOverride: null }; st.clerics[clericId] = clericData; const mode = clericData.mode || "health"; const sheetType = getSheetType(cleric); // Run both async sheet reads in parallel — no dependency between them. const [isTwilight, level] = await Promise.all([ isTwilightCleric(cleric, clericData), getTwilightClericLevel(cleric, clericData) ]); const levelTooltip = clericData.levelOverride !== null && clericData.levelOverride !== undefined ? "Level source: manual override = " + level + " | Formula: 1d6 + " + level : sheetType === "2024" ? "Sheet: D&D 2024 | Level source: base_level = " + level + " | Formula: 1d6 + " + level : "Sheet: D&D 2014 | Level source: class_display (Twilight Domain Cleric) = " + level + " | Formula: 1d6 + " + level; // Resolve the player's page once, then do a single filterObjs pass to build // a charId→token map for all graphics on that page. This replaces up to // 2 × N filterObjs calls (one per tracked character) with a single scan. const playerPageId = getPageForPlayer(playerid); const pageTokenMap = {}; filterObjs(o => { if (o.get("_type") !== "graphic") return false; const rep = o.get("represents"); if (!rep) return false; if (o.get("_pageid") === playerPageId) { pageTokenMap[rep] = o; // page-match wins } else if (!pageTokenMap[rep]) { pageTokenMap[rep] = o; // fallback: any page } return false; // we never actually filter, just populate the map }); // Prime the campaign marker cache before the row loop (no-op if already cached). getCampaignMarkersByTag(); // Build tracked character rows const rowPromises = Object.entries(clericData.tracked).map(async ([charId, data]) => { const char = getObj("character", charId); if (!char) return ""; const pageToken = pageTokenMap[charId] || null; const liveImage = pageToken ? pageToken.get("imgsrc") : data.image; const markerHtml = buildMarkerHtml(pageToken); // Fetch all three HP values in parallel — no dependency between them. const needHp = st.showHealth || st.showBloodied; const [temp, hp, hpMax] = await Promise.all([ safeGetSheetItem(charId, "hp_temp"), needHp ? safeGetSheetItem(charId, "hp") : Promise.resolve("0"), st.showBloodied ? safeGetSheetItem(charId, "hp", "max") : Promise.resolve("0") ]).then(([t, h, m]) => [parseInt(t||"0",10), parseInt(h||"0",10), parseInt(m||"0",10)]); // --- HP display --- let hpDisplay = ""; if (st.showHealth) { let hpStyle = CSS.values; let hpPrefix = ""; if (st.showBloodied && hpMax > 0) { const bloodied = hp <= Math.floor(hpMax / 2); const critical = bloodied && hp < 10; if (bloodied) hpStyle = CSS.values.replace(/color:[^;]+;/, "color:#ff4444;"); if (critical) hpPrefix = "\u2764\uFE0F "; } // HP value is a clickable button that prompts for a new value const hpCmd = "!health --sethp " + charId + " ?{HP change (number, +N, or -N)|" + hp + "}"; hpDisplay = "" + hpPrefix + "HP: " + hp + " "; } // --- Temp HP display --- // In Twilight mode: clicking applies the auto-rolled temp HP // In Health mode: clicking prompts for a manual edit let tempDisplay = ""; if (st.showTempHP) { if (mode === "twilight") { const roll = rollD6() + level; const tempCmd = "!health --settemp " + charId + " " + roll; tempDisplay = "\u2728 Temp: " + temp + ""; } else { const tempCmd = "!health --settemp " + charId + " ?{Temp HP change (number, +N, or -N)|" + temp + "}"; tempDisplay = "Temp: " + temp + ""; } } const removeBtn = buildButton("X", "!health --remove " + charId); // Build ping button for the token image if we have a live page token. // Passes left, top, and pageid through the command so the handler can // call sendPing without needing to re-locate the token. let tokenImgHtml; if (pageToken) { const pingCmd = "!health --ping " + pageToken.get("left") + " " + pageToken.get("top") + " " + pageToken.get("_pageid"); tokenImgHtml = "" + "" + ""; } else { tokenImgHtml = ""; } return ( "" + "" + tokenImgHtml + "" + "" + "
" + sanitize(char.get("name")) + (markerHtml ? "
" + markerHtml + "
" : "") + "
" + "
" + hpDisplay + tempDisplay + "
" + removeBtn + "
" + "
" + "" + "" ); }); const rows = (await Promise.all(rowPromises)).join(""); // Header title: clickable only if this cleric is (or can be) a Twilight cleric. // titleLink is applied directly to the tag — no inner wrapper div — so Roll20's // chat stylesheet cannot override the appearance with link defaults. let titleHtml; if (mode === "twilight") { titleHtml = "Twilight Sanctuary"; } else if (isTwilight) { titleHtml = "Health Monitor"; } else { titleHtml = "
Health Monitor
"; } const html = "
" + "
" + titleHtml + "
" + buildButton("?", "!health --help") + "
" + "
" + "
Healer: " + sanitize(cleric.get("name")) + "
" + "" + rows + "
" + "
" + buildButton("Set Healer", "!health --set-cleric") + buildButton("Track Target", "!health --track @{target|character_id}") + "
" + "
"; whisper(playerid, html); }; /* ------------------------------------ */ /* MENU BUILDERS */ /* ------------------------------------ */ const buildNoClericMenu = () => { const st = getState(); // Build a list of all known clerics from state for quick-assign const clericEntries = Object.entries(st.clerics || {}); let clericList = ""; if (clericEntries.length) { clericList = "
" + "
Known Healers — click to assign
" + ""; clericEntries.forEach(([cid, cdata]) => { const char = getObj("character", cid); if (!char) return; clericList += "" + "" + "" + "" + ""; }); clericList += "
" + "" + "" + "" + sanitize(char.get("name")) + "" + "" + buildButton("Assign", "!health --assign-cleric " + cid) + "
"; } return ( "
" + "
Health Monitor
" + "
No healer assigned.
" + "
" + buildButton("Set Healer (Selected Token)", "!health --set-cleric") + "
" + clericList + "
" ); }; const buildHelpMenu = async (playerid) => { const st = getState(); const clericId = st.playerAssignments[playerid]; const cleric = clericId ? getObj("character", clericId) : null; const clericData = clericId ? (st.clerics[clericId] || {}) : {}; const S = CSS; const row = (label, desc) => "" + "" + buildButton(label, "!health") + "" + "" + desc + "" + ""; // --- Config: known clerics list --- const clericEntries = Object.entries(st.clerics || {}); let clericRows = ""; clericEntries.forEach(([cid, cdata]) => { const char = getObj("character", cid); if (!char) return; const isActive = cid === clericId; const isTwOvr = cdata.isTwilight === true; const lvlOvr = cdata.levelOverride !== null && cdata.levelOverride !== undefined ? cdata.levelOverride : ""; clericRows += "" + "" + "" + "" + "" + "" + sanitize(char.get("name")) + "" + (isActive ? " (active)" : "") + "" + "" + buildButton(isActive ? "Active" : "Use", "!health --assign-cleric " + cid) + " " + buildButton(isTwOvr ? "\uD83C\uDF19 Twilight" : "Twilight?", "!health --toggle-twilight " + cid) + " " + buildButton("Level: " + (lvlOvr !== "" ? lvlOvr : "auto") + " \u270E", "!health --set-level " + cid + " ?{Level override (0 to clear)|0}") + "" + ""; }); const displayBtns = "
" + (st.showHealth ? buildButton("HP: On", "!health --toggle-show-hp") : "HP: Off") + " " + (st.showBloodied ? buildButton("Bloodied: On", "!health --toggle-show-bloodied") : "Bloodied: Off") + " " + (st.showTempHP ? buildButton("Temp: On", "!health --toggle-show-temp") : "Temp: Off") + " " + buildButton("Marker Display", "!health --marker-config") + "
"; const configSection = clericRows ? "
" + "
\u2699\uFE0F Configuration
" + "
Twilight toggle overrides sheet detection. Level 0 clears the override.
" + "" + clericRows + "
" + displayBtns : "
" + "
No healers registered yet. Use Set Healer to add one.
" + displayBtns; return ( "
" + "
" + "
Health Monitor — Help
" + "
" + "❤️" + "
" + "
" + "
Type !health in chat to open the menu. All features are menu-driven. Use !twilight to open directly in Twilight Sanctuary mode.
" + "" + row("Set Healer", "Select your healer token on the map first, then click. Registers that character as your active healer.") + row("Track Target", "Target an ally to add them to the party frame so you can monitor and adjust their HP.") + row("X", "Remove an ally from the party frame.") + row("HP / Temp", "Click the HP or Temp HP value on any tracked character to edit it. Enter a plain number to set, +N to add, or -N to subtract.") + row("Header", "In Health Monitor mode, click the header to enter Twilight Sanctuary mode (if your healer is a Twilight Domain Cleric). In Twilight mode, clicking the Temp HP value rolls 1d6 + cleric level and applies it automatically.") + "
" + "
Hover the healer name to verify the level source and formula in use.
" + configSection + "
" ); }; /* ------------------------------------ */ /* MARKER CONFIG PANEL */ /* ------------------------------------ */ const buildMarkerConfigPanel = () => { const S = CSS; const st = getState(); const disabled = st.disabledMarkers || {}; const markerByTag = getCampaignMarkersByTag(); const allMarkers = Object.values(markerByTag); allMarkers.sort((a, b) => a.name.localeCompare(b.name)); const inactiveStyle = S.button .replace("background:#5a6bc8;", "background:#3a3a5a;") .replace("color:#e6e9ff;", "color:#7a7aaa;"); let markerRows = ""; allMarkers.forEach(m => { const isDisabled = !!disabled[m.tag]; const btnStyle = isDisabled ? inactiveStyle : S.button; const toggleCmd = "!health --toggle-marker " + encodeURIComponent(m.tag); const stateLabel = isDisabled ? "Off" : "On"; markerRows += "" + "" + "" + "" + "" + sanitize(m.name) + "" + "" + "" + stateLabel + "" + "" + ""; }); const homeBtn = "\u2764\uFE0F"; return ( "
" + "
" + "
Token Marker Display
" + "
" + homeBtn + "
" + "
" + "
Toggle which markers appear in the party frame. Off markers are hidden.
" + "
" + buildButton("All On", "!health --markers-all on") + " " + buildButton("All Off", "!health --markers-all off") + "
" + (allMarkers.length ? "" + markerRows + "
" : "
No token markers found in this campaign.
" ) + "
" ); }; /* ------------------------------------ */ /* HP MUTATION HELPER */ /* ------------------------------------ */ /** * parseHpInput(raw, current, max) → number | null * Strips non-numeric characters (except leading +/-), validates, clamps. * Returns null if the result is not a valid integer. */ const parseHpInput = (raw, current, max) => { const cleaned = String(raw).replace(/[^0-9+\-]/g, "").trim(); if (!cleaned) return null; let result; if (/^[+\-]\d+$/.test(cleaned)) { result = current + parseInt(cleaned, 10); } else if (/^\d+$/.test(cleaned)) { result = parseInt(cleaned, 10); } else { return null; } result = Math.max(0, result); if (max > 0) result = Math.min(max, result); return result; }; /* ------------------------------------ */ /* COMMAND HANDLER */ /* ------------------------------------ */ on("chat:message", async (msg) => { if (msg.type !== "api") return; const isHealth = msg.content.startsWith("!health"); const isTwilight = msg.content.startsWith("!twilight"); if (!isHealth && !isTwilight) return; // Re-join args carefully: ?{prompt} responses may contain spaces // but our charIds and values are always single tokens. const parts = msg.content.split(" "); const playerid = msg.playerid; const st = getState(); // !twilight with no subcommand → set mode to twilight, open menu if (isTwilight && parts.length === 1) { const clericId = st.playerAssignments[playerid]; if (clericId && st.clerics[clericId]) { const clericData = st.clerics[clericId]; const cleric = getObj("character", clericId); const canBeTwilight = await isTwilightCleric(cleric, clericData); if (canBeTwilight) { clericData.mode = "twilight"; setAura(clericId, playerid, true); } else { whisper(playerid, "That healer is not a Twilight Domain Cleric. Use the Config panel in Help to override."); } } await renderMenu(playerid); return; } const sub = parts[1]; /* ---------------- SET CLERIC (selected token) ---------------- */ if (sub === "--set-cleric") { const token = getSelected(msg); if (!token) return whisper(playerid, "Select your healer token on the map first."); const char = getChar(token); if (!char) return whisper(playerid, "Token has no character."); st.playerAssignments[playerid] = char.id; if (!st.clerics[char.id]) { st.clerics[char.id] = { image: token.get("imgsrc"), tracked: {}, mode: "health", isTwilight: undefined, levelOverride: null }; } whisper(playerid, "Healer set: " + char.get("name")); await renderMenu(playerid); return; } /* ---------------- ASSIGN CLERIC (from list, no token needed) ---------------- */ if (sub === "--assign-cleric") { const clericId = parts[2]; if (!clericId || !st.clerics[clericId]) return whisper(playerid, "Unknown healer."); const char = getObj("character", clericId); if (!char) return whisper(playerid, "Healer character no longer exists."); st.playerAssignments[playerid] = clericId; whisper(playerid, "Healer set: " + char.get("name")); await renderMenu(playerid); return; } /* ---------------- TRACK TARGET ---------------- */ if (sub === "--track") { const charId = parts[2]; if (!charId) return whisper(playerid, "No target provided. Use the Track Target button."); const char = getObj("character", charId); if (!char) return whisper(playerid, "Invalid character."); const clericId = st.playerAssignments[playerid]; if (!clericId) return whisper(playerid, "No healer assigned."); st.clerics[clericId] = st.clerics[clericId] || { tracked: {}, mode: "health", isTwilight: undefined, levelOverride: null }; const token = findObjs({ type: "graphic", represents: charId })[0]; st.clerics[clericId].tracked[charId] = { image: token ? token.get("imgsrc") : "" }; await renderMenu(playerid); return; } /* ---------------- PING ---------------- */ if (sub === "--ping") { // args: !health --ping // visibleTo is set to playerid so only the sender sees the ping and // has their view moved. No other players are affected. const left = parseFloat(parts[2]); const top = parseFloat(parts[3]); const pageId = parts[4]; if (!isNaN(left) && !isNaN(top) && pageId) { sendPing(left, top, pageId, playerid, true, playerid); } return; } /* ---------------- REMOVE ---------------- */ if (sub === "--remove") { const charId = parts[2]; const clericId = st.playerAssignments[playerid]; if (clericId && st.clerics[clericId]) delete st.clerics[clericId].tracked[charId]; await renderMenu(playerid); return; } /* ---------------- MODE TOGGLE ---------------- */ if (sub === "--mode") { const newMode = parts[2]; // "twilight" or "health" const clericId = st.playerAssignments[playerid]; if (!clericId || !st.clerics[clericId]) return whisper(playerid, "No healer assigned."); const clericData = st.clerics[clericId]; const cleric = getObj("character", clericId); if (newMode === "twilight") { const canBeTwilight = await isTwilightCleric(cleric, clericData); if (!canBeTwilight) return whisper(playerid, "That healer is not a Twilight Domain Cleric. Use the Config panel in Help to override."); clericData.mode = "twilight"; setAura(clericId, playerid, true); } else { clericData.mode = "health"; setAura(clericId, playerid, false); } await renderMenu(playerid); return; } /* ---------------- SET HP ---------------- */ if (sub === "--sethp") { const charId = parts[2]; const raw = parts.slice(3).join(" "); const char = getObj("character", charId); if (!char) return whisper(playerid, "Unknown character."); const current = parseInt((await safeGetSheetItem(charId, "hp")) || "0", 10); const max = parseInt((await safeGetSheetItem(charId, "hp", "max")) || "0", 10); const newVal = parseHpInput(raw, current, max); if (newVal === null) return whisper(playerid, "Invalid input \u201c" + sanitize(raw) + "\u201d. Use a number, +N, or -N."); await setSheetItem(charId, "hp", newVal); notifyChange(playerid, char, "hp", current, newVal); await renderMenu(playerid); return; } /* ---------------- SET TEMP HP ---------------- */ if (sub === "--settemp") { const charId = parts[2]; const raw = parts.slice(3).join(" "); const char = getObj("character", charId); if (!char) return whisper(playerid, "Unknown character."); const clericId = st.playerAssignments[playerid]; const clericData = clericId ? (st.clerics[clericId] || {}) : {}; const mode = clericData.mode || "health"; const current = parseInt((await safeGetSheetItem(charId, "hp_temp")) || "0", 10); let newVal; if (mode === "twilight") { // raw is the pre-rolled value passed directly from the button href newVal = parseInt(raw, 10); if (isNaN(newVal)) return whisper(playerid, "Internal error: invalid roll value."); } else { newVal = parseHpInput(raw, current, 0); // no max cap on temp HP if (newVal === null) return whisper(playerid, "Invalid input \u201c" + sanitize(raw) + "\u201d. Use a number, +N, or -N."); } await setSheetItem(charId, "hp_temp", newVal); notifyChange(playerid, char, "hp_temp", current, newVal); await renderMenu(playerid); return; } /* ---------------- TOGGLE TWILIGHT OVERRIDE ---------------- */ if (sub === "--toggle-twilight") { const clericId = parts[2]; if (!clericId || !st.clerics[clericId]) return whisper(playerid, "Unknown healer."); const cd = st.clerics[clericId]; cd.isTwilight = !cd.isTwilight; // If we just turned it off and this cleric was in twilight mode, revert to health if (!cd.isTwilight && cd.mode === "twilight") { cd.mode = "health"; setAura(clericId, playerid, false); } whisper(playerid, "Twilight Domain override for that healer is now: " + (cd.isTwilight ? "ON" : "OFF")); whisper(playerid, await buildHelpMenu(playerid)); return; } /* ---------------- SET LEVEL OVERRIDE ---------------- */ if (sub === "--set-level") { const clericId = parts[2]; const rawLevel = parts[3]; if (!clericId || !st.clerics[clericId]) return whisper(playerid, "Unknown healer."); const parsed = parseInt(rawLevel, 10); if (isNaN(parsed) || parsed < 0) return whisper(playerid, "Invalid level. Enter a positive number, or 0 to clear the override."); st.clerics[clericId].levelOverride = parsed === 0 ? null : parsed; whisper(playerid, "Level override " + (parsed === 0 ? "cleared (using auto-detect)." : "set to " + parsed + ".")); whisper(playerid, await buildHelpMenu(playerid)); return; } /* ---------------- MARKER CONFIG PANEL ---------------- */ if (sub === "--marker-config") { whisper(playerid, buildMarkerConfigPanel()); return; } /* ---------------- TOGGLE SINGLE MARKER ---------------- */ if (sub === "--toggle-marker") { const tag = decodeURIComponent(parts[2] || ""); if (!tag) return; const disabled = st.disabledMarkers || {}; st.disabledMarkers = disabled; if (disabled[tag]) { delete disabled[tag]; } else { disabled[tag] = true; } whisper(playerid, buildMarkerConfigPanel()); return; } /* ---------------- MARKERS ALL ON / OFF ---------------- */ if (sub === "--markers-all") { const val = parts[2]; // "on" or "off" if (val === "off") { const markerByTag = getCampaignMarkersByTag(); st.disabledMarkers = {}; Object.keys(markerByTag).forEach(tag => { st.disabledMarkers[tag] = true; }); } else { st.disabledMarkers = {}; } whisper(playerid, buildMarkerConfigPanel()); return; } /* ---------------- TOGGLE DISPLAY SETTINGS ---------------- */ if (sub === "--toggle-show-hp") { st.showHealth = !st.showHealth; whisper(playerid, "Show HP: " + (st.showHealth ? "ON" : "OFF")); whisper(playerid, await buildHelpMenu(playerid)); return; } if (sub === "--toggle-show-bloodied") { st.showBloodied = !st.showBloodied; whisper(playerid, "Bloodied display: " + (st.showBloodied ? "ON" : "OFF")); whisper(playerid, await buildHelpMenu(playerid)); return; } if (sub === "--toggle-show-temp") { st.showTempHP = !st.showTempHP; whisper(playerid, "Show Temp HP: " + (st.showTempHP ? "ON" : "OFF")); whisper(playerid, await buildHelpMenu(playerid)); return; } /* ---------------- HELP / CONFIG ---------------- */ if (sub === "--help") { whisper(playerid, await buildHelpMenu(playerid)); return; } /* ---------------- DEFAULT MENU ---------------- */ await renderMenu(playerid); }); // Warm the campaign marker cache as soon as the API sandbox is ready. // This ensures the first menu render never pays the parse cost. on("ready", () => { getCampaignMarkersByTag(); log("HealthMonitor v. " + version + " ready. Use !health to activate."); }); return {}; })();