/** * Activate HP history tracking that stores entries in flags only. * Pair with macro_enable-history-tab.js to view the data inside a History tab. */ const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null; const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here"; const HP_PATH = "system.attributes.hp.value"; const FLAG_SCOPE = "world"; const FLAG_KEY = "pf1HpHistory"; const UPDATE_CONTEXT_FLAG = "hpHistoryFlags"; const MAX_ROWS = 50; if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") { return ui.notifications.warn("Set ACTOR_ID before running the macro."); } game.pf1 ??= {}; game.pf1.hpHistoryFlags ??= { sources: new Map(), current: {}, hooks: {} }; const state = game.pf1.hpHistoryFlags; const logKey = `pf1-hp-history-flags-${ACTOR_ID}`; if (state.hooks[logKey]) { Hooks.off("updateActor", state.hooks[logKey]); delete state.hooks[logKey]; } ensureDamageSourceTracking(); primeSnapshot(game.actors.get(ACTOR_ID) ?? TARGET_ACTOR); state.hooks[logKey] = Hooks.on("updateActor", (actor, change, options, userId) => { if (actor.id !== ACTOR_ID) return; if (options?.[UPDATE_CONTEXT_FLAG]) return; const newHP = readHpValue(actor); if (newHP === null) return; const previous = state.current[ACTOR_ID]; if (previous === undefined) { state.current[ACTOR_ID] = newHP; return; } if (Object.is(newHP, previous)) return; const diff = newHP - previous; const diffText = diff >= 0 ? `+${diff}` : `${diff}`; const user = game.users.get(userId); const source = consumeDamageSource(actor.id) ?? inferManualSource(diff); state.current[ACTOR_ID] = newHP; appendHistoryEntry(actor, { hp: newHP, diff: diffText, user: user?.name ?? "System", source: source ?? "", }).catch((err) => console.error("HP History Flags | Failed to append entry", err)); }); const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID; ui.notifications.info(`HP flag history tracking active for ${actorName}.`); async function appendHistoryEntry(actor, entry) { const existing = (await actor.getFlag(FLAG_SCOPE, FLAG_KEY)) ?? []; existing.unshift({ timestamp: Date.now(), hp: entry.hp, diff: entry.diff, user: entry.user, source: entry.source, }); if (existing.length > MAX_ROWS) existing.splice(MAX_ROWS); await actor.setFlag(FLAG_SCOPE, FLAG_KEY, existing); } function consumeDamageSource(actorId) { const entry = state.sources.get(actorId); if (!entry) return null; state.sources.delete(actorId); return entry.label; } function inferManualSource(diff) { if (!Number.isFinite(diff) || diff === 0) return null; return diff > 0 ? "Manual healing" : "Manual damage"; } function ensureDamageSourceTracking() { 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 History Flags | Failed to record source", err); } return original.call(this, value, options); }; state.applyDamageWrapped = true; } 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; state.sources.set(ACTOR_ID, { label, ts: Date.now() }); } 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; } function primeSnapshot(actor) { const hp = readHpValue(actor); if (hp === null) return; state.current[ACTOR_ID] = hp; } 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); } 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; } 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 History Flags | Failed to resolve actor UUID", err); } } const id = metadata.actor.split(".").pop(); return game.actors.get(id) ?? null; } function resolveItemFromMetadata(actor, metadata = {}) { if (!metadata.item || !(actor?.items instanceof Collection)) return null; return actor.items.get(metadata.item) ?? null; }