/** * 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}
Timestamp Totals Δ User Source
${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, "\\$&"); }