/** * Activate HP tracking for a single actor. * Drop this script into a Foundry macro. */ // Change this lookup (or set ACTOR_ID directly) to match the actor you want to monitor. const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null; const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here"; // e.g. replace string with actor id const HP_PATH = "system.attributes.hp.value"; if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") { return ui.notifications.warn("Set ACTOR_ID before running the macro."); } game.pf1 ??= {}; game.pf1.hpLogger ??= {}; game.pf1.hpLogger.sources ??= new Map(); game.pf1.hpLogger.current ??= {}; const logKey = `pf1-hp-log-${ACTOR_ID}`; // Remove existing hook for the same actor so re-running stays idempotent. if (game.pf1.hpLogger[logKey]) { Hooks.off("updateActor", game.pf1.hpLogger[logKey]); delete game.pf1.hpLogger[logKey]; } ensureDamageSourceTracking(); primeHpSnapshot(game.actors.get(ACTOR_ID) ?? TARGET_ACTOR); game.pf1.hpLogger[logKey] = Hooks.on("updateActor", (actor, change, options, userId) => { if (actor.id !== ACTOR_ID) return; const newHP = readHpValue(actor); if (newHP === null) return; const previous = game.pf1.hpLogger.current[ACTOR_ID]; if (previous === undefined) { game.pf1.hpLogger.current[ACTOR_ID] = newHP; return; } if (Object.is(newHP, previous)) return; // No HP change. const diff = newHP - previous; const diffText = diff >= 0 ? `+${diff}` : `${diff}`; const user = game.users.get(userId); const source = consumeDamageSource(actor.id) ?? inferManualSource(diff); const sourceLine = source ? `
Source: ${source}` : ""; ChatMessage.create({ content: `HP Monitor
${actor.name} HP: ${previous} -> ${newHP} (${diffText})${user ? ` by ${user.name}` : ""}${sourceLine}`, speaker: { alias: "System" }, }); game.pf1.hpLogger.current[ACTOR_ID] = newHP; }); const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID; ui.notifications.info(`HP change hook active for ${actorName}.`); /** * Consume and clear the stored damage source for this actor, if any. * @param {string} actorId */ function consumeDamageSource(actorId) { const entry = game.pf1.hpLogger.sources.get(actorId); if (!entry) return null; game.pf1.hpLogger.sources.delete(actorId); return entry.label; } /** * Provide a fallback label when the change was manual or unidentified. * @param {number} diff */ function inferManualSource(diff) { if (!Number.isFinite(diff) || diff === 0) return null; return diff > 0 ? "Manual healing" : "Manual damage"; } /** * Ensure ActorPF.applyDamage is wrapped so we can capture the originating chat card. */ function ensureDamageSourceTracking() { const state = game.pf1.hpLogger; if (state.applyDamageWrapped) return; const ActorPF = pf1?.documents?.actor?.ActorPF; if (!ActorPF?.applyDamage) return; const original = ActorPF.applyDamage; ActorPF.applyDamage = async function wrappedApplyDamage(value, options = {}) { try { noteDamageSource(value, options); } catch (err) { console.warn("HP Logger | Failed to record damage source", err); } return original.call(this, value, options); }; state.applyDamageWrapped = true; } /** * Record the best-guess label for the HP change if the tracked actor is among the targets. * @param {number} value * @param {object} options */ function noteDamageSource(value, options) { const actors = resolveActorTargets(options?.targets); if (!actors.some((a) => a?.id === ACTOR_ID)) return; const label = buildSourceLabel(value, options); if (!label) return; game.pf1.hpLogger.sources.set(ACTOR_ID, { label, ts: Date.now() }); } /** * Try to describe where the HP change originated. * @param {number} value * @param {object} options */ function buildSourceLabel(value, options = {}) { const info = options.message?.flags?.pf1?.identifiedInfo ?? {}; const metadata = options.message?.flags?.pf1?.metadata ?? {}; const fromChatFlavor = options.message?.flavor?.trim(); const actorDoc = resolveActorFromMetadata(metadata); const itemDoc = actorDoc ? resolveItemFromMetadata(actorDoc, metadata) : null; const actorName = actorDoc?.name ?? options.message?.speaker?.alias ?? null; const actionName = info.actionName ?? info.name ?? itemDoc?.name ?? fromChatFlavor ?? null; let label = null; if (actorName && actionName) label = `${actorName} -> ${actionName}`; else if (actionName) label = actionName; else if (actorName) label = actorName; else label = value < 0 ? "Healing" : "Damage"; if (options.isCritical) label += " (Critical)"; if (options.asNonlethal) label += " [Nonlethal]"; return label; } /** * Store the actor's current HP so the first change has a baseline. * @param {Actor|null} actor */ function primeHpSnapshot(actor) { const hp = readHpValue(actor); if (hp === null) return; game.pf1.hpLogger.current[ACTOR_ID] = hp; } /** * Resolve actors targeted by the damage application. * Falls back to currently controlled tokens if no explicit targets were supplied. * @param {Array} targets * @returns {Actor[]} */ function resolveActorTargets(targets) { let list = []; if (Array.isArray(targets) && targets.length) list = targets; else list = canvas?.tokens?.controlled ?? []; return list .map((entry) => { if (!entry) return null; if (entry instanceof Actor) return entry; if (entry.actor) return entry.actor; if (entry.document?.actor) return entry.document.actor; return null; }) .filter((actor) => actor instanceof Actor && actor.id); } /** * Read HP from the actor using several possible data paths. * @param {Actor|null} actor * @returns {number|null} */ function readHpValue(actor) { if (!actor) return null; const candidates = [ HP_PATH, HP_PATH.replace(/^system\./, "data."), HP_PATH.replace(/^system\./, ""), ]; for (const path of candidates) { const value = foundry.utils.getProperty(actor, path); if (value !== undefined) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : null; } if (actor.system) { const trimmed = path.startsWith("system.") ? path.slice(7) : path; const systemValue = foundry.utils.getProperty(actor.system, trimmed); if (systemValue !== undefined) { const numeric = Number(systemValue); return Number.isFinite(numeric) ? numeric : null; } } } return null; } /** * Fetch the actor referenced in the chat card metadata, if any. * @param {object} metadata * @returns {Actor|null} */ function resolveActorFromMetadata(metadata = {}) { if (!metadata.actor) return null; if (typeof fromUuidSync === "function") { try { const doc = fromUuidSync(metadata.actor); if (doc instanceof Actor) return doc; } catch (err) { console.warn("HP Logger | Failed to resolve actor UUID", err); } } // Fallback: assume uuid ends with actor id const id = metadata.actor.split(".").pop(); return game.actors.get(id) ?? null; } /** * Fetch the item referenced in the chat card metadata from the given actor. * @param {Actor} actor * @param {object} metadata * @returns {Item|null} */ function resolveItemFromMetadata(actor, metadata = {}) { if (!metadata.item || !(actor?.items instanceof Collection)) return null; return actor.items.get(metadata.item) ?? null; }