From 30aa03c6db891cff3fc1f42e6aae057004282036 Mon Sep 17 00:00:00 2001 From: "centron\\schwoerer" Date: Fri, 14 Nov 2025 10:30:00 +0100 Subject: [PATCH] history --- src/macro_activate-currency-history.js | 300 ++++++++++++++ src/macro_activate-hp-history.js | 369 ++++++++++++++++++ .../macro_activate-hp-tracking.js | 0 src/macro_activate-xp-history.js | 285 ++++++++++++++ src/macro_stop-currency-history.js | 28 ++ src/macro_stop-hp-history.js | 32 ++ .../macro_stop-hp-tracking.js | 0 src/macro_stop-xp-history.js | 28 ++ 8 files changed, 1042 insertions(+) create mode 100644 src/macro_activate-currency-history.js create mode 100644 src/macro_activate-hp-history.js rename macro_activate-hp-tracking.js => src/macro_activate-hp-tracking.js (100%) create mode 100644 src/macro_activate-xp-history.js create mode 100644 src/macro_stop-currency-history.js create mode 100644 src/macro_stop-hp-history.js rename macro_stop-hp-tracking.js => src/macro_stop-hp-tracking.js (100%) create mode 100644 src/macro_stop-xp-history.js diff --git a/src/macro_activate-currency-history.js b/src/macro_activate-currency-history.js new file mode 100644 index 00000000..b863d770 --- /dev/null +++ b/src/macro_activate-currency-history.js @@ -0,0 +1,300 @@ +/** + * Track currency changes for a single actor and store them in ===Currency History=== notes. + */ + +const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null; +const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here"; +const CURRENCY_PATH = "system.currency"; +const UPDATE_CONTEXT_FLAG = "currencyHistory"; +const FLAG_SCOPE = "world"; +const FLAG_KEY = "pf1CurrencyHistory"; +const BLOCK_NAME = "pf1-currency-history"; +const MAX_ROWS = 50; +const BLOCK_START = ""; +const BLOCK_END = ""; +const COIN_ORDER = ["pp", "gp", "sp", "cp"]; + +if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") { + return ui.notifications.warn("Set ACTOR_ID before running the macro."); +} + +game.pf1 ??= {}; +game.pf1.currencyHistory ??= {}; +const state = game.pf1.currencyHistory; +state.sources ??= new Map(); +state.current ??= {}; +state.hooks ??= {}; + +const logKey = `pf1-currency-history-${ACTOR_ID}`; + +if (state.hooks[logKey]) { + Hooks.off("updateActor", state.hooks[logKey]); + delete state.hooks[logKey]; +} + +ensureDamageSourceTracking(); +primeCurrencySnapshot(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 newCurrency = readCurrencyValue(actor); + if (!newCurrency) return; + + const previous = state.current[ACTOR_ID]; + if (!previous) { + state.current[ACTOR_ID] = newCurrency; + return; + } + + if (currencyEquals(previous, newCurrency)) return; + + const diff = diffCurrency(previous, newCurrency); + const diffText = formatCurrency(diff, true); + const user = game.users.get(userId); + const source = consumeDamageSource(actor.id) ?? "Manual change"; + + state.current[ACTOR_ID] = newCurrency; + + appendHistoryRow(actor, { + value: formatCurrency(newCurrency), + diffText, + user, + source, + snapshot: newCurrency, + }).catch((err) => console.error("Currency History | Failed to append entry", err)); +}); + +const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID; +ui.notifications.info(`Currency history tracking active for ${actorName}.`); + +async function appendHistoryRow(actor, entry) { + const existing = (await actor.getFlag(FLAG_SCOPE, FLAG_KEY)) ?? []; + const newEntry = { + timestamp: Date.now(), + value: entry.value, + diff: entry.diffText, + user: entry.user?.name ?? "System", + source: entry.source ?? "", + }; + existing.unshift(newEntry); + if (existing.length > MAX_ROWS) existing.splice(MAX_ROWS); + + const notes = actor.system.details?.notes?.value ?? ""; + const block = renderHistoryBlock(existing); + const updatedNotes = injectHistoryBlock(notes, block); + + await actor.update( + { + [`flags.${FLAG_SCOPE}.${FLAG_KEY}`]: existing, + "system.details.notes.value": updatedNotes, + }, + { [UPDATE_CONTEXT_FLAG]: true } + ); +} + +function renderHistoryBlock(entries) { + const rows = entries + .map( + (e) => ` + + ${new Date(e.timestamp).toLocaleString()} + ${e.value} + ${e.diff} + ${e.user ?? ""} + ${e.source ?? ""} + ` + ) + .join(""); + + const blockId = `${BLOCK_NAME}-${ACTOR_ID}`; + return ` +${BLOCK_START} +
+
+ ===Currency History=== (click to collapse) +
+ + +
+ + + + + + + + + + + + ${rows} + +
TimestampTotalsΔUserSource
+
+ +
+${BLOCK_END}`.trim(); +} + +function injectHistoryBlock(notes, block) { + const blockPattern = new RegExp(`${escapeRegExp(BLOCK_START)}[\\s\\S]*?${escapeRegExp(BLOCK_END)}`, "g"); + let cleaned = notes.replace(blockPattern, ""); + if (cleaned === notes) { + const sectionPattern = new RegExp(`]*data-history-block="${BLOCK_NAME}"[^>]*>[\\s\\S]*?<\\/section>`, "g"); + cleaned = notes.replace(sectionPattern, ""); + } + cleaned = cleaned.trim(); + const separator = cleaned.length && !cleaned.endsWith("\n") ? "\n\n" : ""; + return `${cleaned}${separator}${block}`; +} + +function consumeDamageSource(actorId) { + const entry = state.sources.get(actorId); + if (!entry) return null; + state.sources.delete(actorId); + return entry.label; +} + +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("Currency History | 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; + + if (actorName && actionName) return `${actorName} -> ${actionName}`; + if (actionName) return actionName; + if (actorName) return actorName; + return null; +} + +function primeCurrencySnapshot(actor) { + const currency = readCurrencyValue(actor); + if (!currency) return; + state.current[ACTOR_ID] = currency; +} + +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 readCurrencyValue(actor) { + if (!actor) return null; + const base = foundry.utils.getProperty(actor, CURRENCY_PATH) ?? actor.system?.currency; + if (!base) return null; + const clone = {}; + for (const coin of COIN_ORDER) clone[coin] = Number(base[coin] ?? 0); + return clone; +} + +function currencyEquals(a, b) { + return COIN_ORDER.every((coin) => (a[coin] ?? 0) === (b[coin] ?? 0)); +} + +function diffCurrency(prev, next) { + const diff = {}; + for (const coin of COIN_ORDER) diff[coin] = (next[coin] ?? 0) - (prev[coin] ?? 0); + return diff; +} + +function formatCurrency(obj, skipZero = false) { + return COIN_ORDER.map((coin) => obj[coin] ?? 0) + .map((value, idx) => { + if (skipZero && value === 0) return null; + const label = COIN_ORDER[idx]; + const v = skipZero ? (value > 0 ? `+${value}` : `${value}`) : value; + return `${label}:${v}`; + }) + .filter(Boolean) + .join(" "); +} + +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("Currency History | 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; +} + +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/macro_activate-hp-history.js b/src/macro_activate-hp-history.js new file mode 100644 index 00000000..7c1dc396 --- /dev/null +++ b/src/macro_activate-hp-history.js @@ -0,0 +1,369 @@ +/** + * Activate HP history tracking for a single actor. + * Instead of sending chat messages, HP changes are written into an + * ===HP History=== table inside the actor's Notes field. + */ + +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 UPDATE_CONTEXT_FLAG = "hpHistory"; +const FLAG_SCOPE = "world"; +const FLAG_KEY = "pf1HpHistory"; +const BLOCK_NAME = "pf1-hp-history"; +const MAX_ROWS = 50; +const BLOCK_START = ""; +const BLOCK_END = ""; + +if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") { + return ui.notifications.warn("Set ACTOR_ID before running the macro."); +} + +game.pf1 ??= {}; +game.pf1.hpHistory ??= {}; +const state = game.pf1.hpHistory; +state.sources ??= new Map(); +state.current ??= {}; +state.hooks ??= {}; + +const logKey = `pf1-hp-history-${ACTOR_ID}`; + +// Remove any previous hook for this actor so the macro is idempotent. +if (state.hooks[logKey]) { + Hooks.off("updateActor", state.hooks[logKey]); + delete state.hooks[logKey]; +} + +ensureDamageSourceTracking(); +primeHpSnapshot(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; // Ignore our own notes updates. + + 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; + + appendHistoryRow(actor, { + previous, + newHP, + diff, + diffText, + user, + source, + }).catch((err) => console.error("HP History | Failed to append entry", err)); +}); + +const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID; +ui.notifications.info(`HP history tracking active for ${actorName}.`); + +/** + * Append an entry to the Notes table. + * @param {Actor} actor + * @param {object} entry + */ +async function appendHistoryRow(actor, entry) { + const existing = (await actor.getFlag(FLAG_SCOPE, FLAG_KEY)) ?? []; + const newEntry = { + timestamp: Date.now(), + hp: entry.newHP, + diff: entry.diffText, + user: entry.user?.name ?? "System", + source: entry.source ?? "", + }; + existing.unshift(newEntry); + if (existing.length > MAX_ROWS) existing.splice(MAX_ROWS); + + const notes = actor.system.details?.notes?.value ?? ""; + const block = renderHistoryBlock(existing); + const updatedNotes = injectHistoryBlock(notes, block); + + await actor.update( + { + [`flags.${FLAG_SCOPE}.${FLAG_KEY}`]: existing, + "system.details.notes.value": updatedNotes, + }, + { [UPDATE_CONTEXT_FLAG]: true } + ); +} + +/** + * Consume and clear the stored damage source for this actor, if any. + * @param {string} actorId + */ +function consumeDamageSource(actorId) { + const entry = state.sources.get(actorId); + if (!entry) return null; + state.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 originating chat data. + */ +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 | 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; + state.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; + state.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 History | Failed to resolve actor UUID", err); + } + } + + 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; +} + +/** + * Render the HP history heading and table HTML. + * @param {Array} entries + * @returns {string} + */ +function renderHistoryBlock(entries) { + const rows = entries + .map( + (e) => ` + + ${new Date(e.timestamp).toLocaleString()} + ${e.hp} + ${e.diff} + ${e.user ?? ""} + ${e.source ?? ""} + ` + ) + .join(""); + + const blockId = `${BLOCK_NAME}-${ACTOR_ID}`; + const table = ` +${BLOCK_START} +
+
+ ===HP History=== (click to collapse) +
+ + +
+ + + + + + + + + + + + ${rows} + +
TimestampHPΔUserSource
+
+ +
+${BLOCK_END}`.trim(); + + return table; +} + +function injectHistoryBlock(notes, block) { + const blockPattern = new RegExp(`${escapeRegExp(BLOCK_START)}[\\s\\S]*?${escapeRegExp(BLOCK_END)}`, "g"); + let cleaned = notes.replace(blockPattern, ""); + if (cleaned === notes) { + const sectionPattern = new RegExp(`]*data-history-block="${BLOCK_NAME}"[^>]*>[\\s\\S]*?<\\/section>`, "g"); + cleaned = notes.replace(sectionPattern, ""); + } + cleaned = cleaned.trim(); + const separator = cleaned.length && !cleaned.endsWith("\n") ? "\n\n" : ""; + return `${cleaned}${separator}${block}`; +} + +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/macro_activate-hp-tracking.js b/src/macro_activate-hp-tracking.js similarity index 100% rename from macro_activate-hp-tracking.js rename to src/macro_activate-hp-tracking.js diff --git a/src/macro_activate-xp-history.js b/src/macro_activate-xp-history.js new file mode 100644 index 00000000..27d3e5a2 --- /dev/null +++ b/src/macro_activate-xp-history.js @@ -0,0 +1,285 @@ +/** + * Track XP changes for a single actor and record them inside ===XP History=== notes. + */ + +const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null; +const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here"; +const XP_PATH = "system.details.xp.value"; +const UPDATE_CONTEXT_FLAG = "xpHistory"; +const FLAG_SCOPE = "world"; +const FLAG_KEY = "pf1XpHistory"; +const BLOCK_NAME = "pf1-xp-history"; +const MAX_ROWS = 50; +const BLOCK_START = ""; +const BLOCK_END = ""; + +if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") { + return ui.notifications.warn("Set ACTOR_ID before running the macro."); +} + +game.pf1 ??= {}; +game.pf1.xpHistory ??= {}; +const state = game.pf1.xpHistory; +state.sources ??= new Map(); +state.current ??= {}; +state.hooks ??= {}; + +const logKey = `pf1-xp-history-${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 newXP = readXpValue(actor); + if (newXP === null) return; + + const previous = state.current[ACTOR_ID]; + if (previous === undefined) { + state.current[ACTOR_ID] = newXP; + return; + } + if (Object.is(previous, newXP)) return; + + const diff = newXP - previous; + const diffText = diff >= 0 ? `+${diff}` : `${diff}`; + const user = game.users.get(userId); + const source = consumeDamageSource(actor.id) ?? inferManualSource(diff); + + state.current[ACTOR_ID] = newXP; + + appendHistoryRow(actor, { + value: newXP, + diffText, + user, + source, + }).catch((err) => console.error("XP History | Failed to append entry", err)); +}); + +const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID; +ui.notifications.info(`XP history tracking active for ${actorName}.`); + +async function appendHistoryRow(actor, entry) { + const existing = (await actor.getFlag(FLAG_SCOPE, FLAG_KEY)) ?? []; + const newEntry = { + timestamp: Date.now(), + value: entry.value, + diff: entry.diffText, + user: entry.user?.name ?? "System", + source: entry.source ?? "", + }; + existing.unshift(newEntry); + if (existing.length > MAX_ROWS) existing.splice(MAX_ROWS); + + const notes = actor.system.details?.notes?.value ?? ""; + const block = renderHistoryBlock(existing); + const updatedNotes = injectHistoryBlock(notes, block); + + await actor.update( + { + [`flags.${FLAG_SCOPE}.${FLAG_KEY}`]: existing, + "system.details.notes.value": updatedNotes, + }, + { [UPDATE_CONTEXT_FLAG]: true } + ); +} + +function renderHistoryBlock(entries) { + const rows = entries + .map( + (e) => ` + + ${new Date(e.timestamp).toLocaleString()} + ${e.value} + ${e.diff} + ${e.user ?? ""} + ${e.source ?? ""} + ` + ) + .join(""); + + const blockId = `${BLOCK_NAME}-${ACTOR_ID}`; + return ` +${BLOCK_START} +
+
+ ===XP History=== (click to collapse) +
+ + +
+ + + + + + + + + + + + ${rows} + +
TimestampXPΔUserSource
+
+ +
+${BLOCK_END}`.trim(); +} + +function injectHistoryBlock(notes, block) { + const blockPattern = new RegExp(`${escapeRegExp(BLOCK_START)}[\\s\\S]*?${escapeRegExp(BLOCK_END)}`, "g"); + let cleaned = notes.replace(blockPattern, ""); + if (cleaned === notes) { + const sectionPattern = new RegExp(`]*data-history-block="${BLOCK_NAME}"[^>]*>[\\s\\S]*?<\\/section>`, "g"); + cleaned = notes.replace(sectionPattern, ""); + } + cleaned = cleaned.trim(); + const separator = cleaned.length && !cleaned.endsWith("\n") ? "\n\n" : ""; + return `${cleaned}${separator}${block}`; +} + +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 increase" : "Manual decrease"; +} + +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("XP History | 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 ? "Decrease" : "Increase"; + + if (options.isCritical) label += " (Critical)"; + if (options.asNonlethal) label += " [Nonlethal]"; + + return label; +} + +function primeSnapshot(actor) { + const xp = readXpValue(actor); + if (xp === null) return; + state.current[ACTOR_ID] = xp; +} + +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 readXpValue(actor) { + if (!actor) return null; + const value = foundry.utils.getProperty(actor, XP_PATH) ?? foundry.utils.getProperty(actor.system ?? {}, XP_PATH.replace(/^system\./, "")); + if (value === undefined) return null; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : 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("XP History | 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; +} + +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/macro_stop-currency-history.js b/src/macro_stop-currency-history.js new file mode 100644 index 00000000..63f01f9e --- /dev/null +++ b/src/macro_stop-currency-history.js @@ -0,0 +1,28 @@ +/** + * Stop currency history tracking for the configured actor. + */ + +const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null; +const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here"; + +if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") { + return ui.notifications.warn("Set ACTOR_ID before running the macro."); +} + +const state = game.pf1?.currencyHistory; +if (!state) return ui.notifications.info("Currency history tracking is not active."); + +const logKey = `pf1-currency-history-${ACTOR_ID}`; +const handler = state.hooks?.[logKey]; + +if (!handler) { + return ui.notifications.info(`No currency history hook found for ${TARGET_ACTOR?.name ?? ACTOR_ID}.`); +} + +Hooks.off("updateActor", handler); +delete state.hooks[logKey]; +state.sources?.delete(ACTOR_ID); +if (state.current) delete state.current[ACTOR_ID]; + +const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID; +ui.notifications.info(`Currency history tracking disabled for ${actorName}.`); diff --git a/src/macro_stop-hp-history.js b/src/macro_stop-hp-history.js new file mode 100644 index 00000000..8fef6514 --- /dev/null +++ b/src/macro_stop-hp-history.js @@ -0,0 +1,32 @@ +/** + * Deactivate the HP history tracker and stop writing to the Notes table. + */ + +const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null; +const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here"; + +if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") { + return ui.notifications.warn("Set ACTOR_ID before running the macro."); +} + +game.pf1 ??= {}; +const state = game.pf1.hpHistory; + +if (!state) { + return ui.notifications.info("HP history tracking is not active."); +} + +const logKey = `pf1-hp-history-${ACTOR_ID}`; +const handler = state.hooks?.[logKey]; + +if (!handler) { + return ui.notifications.info(`No HP history hook found for ${TARGET_ACTOR?.name ?? ACTOR_ID}.`); +} + +Hooks.off("updateActor", handler); +delete state.hooks[logKey]; +state.sources?.delete(ACTOR_ID); +if (state.current) delete state.current[ACTOR_ID]; + +const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID; +ui.notifications.info(`HP history tracking disabled for ${actorName}.`); diff --git a/macro_stop-hp-tracking.js b/src/macro_stop-hp-tracking.js similarity index 100% rename from macro_stop-hp-tracking.js rename to src/macro_stop-hp-tracking.js diff --git a/src/macro_stop-xp-history.js b/src/macro_stop-xp-history.js new file mode 100644 index 00000000..715322c3 --- /dev/null +++ b/src/macro_stop-xp-history.js @@ -0,0 +1,28 @@ +/** + * Stop XP history tracking for the configured actor. + */ + +const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null; +const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here"; + +if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") { + return ui.notifications.warn("Set ACTOR_ID before running the macro."); +} + +const state = game.pf1?.xpHistory; +if (!state) return ui.notifications.info("XP history tracking is not active."); + +const logKey = `pf1-xp-history-${ACTOR_ID}`; +const handler = state.hooks?.[logKey]; + +if (!handler) { + return ui.notifications.info(`No XP history hook found for ${TARGET_ACTOR?.name ?? ACTOR_ID}.`); +} + +Hooks.off("updateActor", handler); +delete state.hooks[logKey]; +state.sources?.delete(ACTOR_ID); +if (state.current) delete state.current[ACTOR_ID]; + +const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID; +ui.notifications.info(`XP history tracking disabled for ${actorName}.`);