diff --git a/src/macro_enable-history-dialog.js b/src/macro_enable-history-dialog.js index 29d152cf..6bd7e750 100644 --- a/src/macro_enable-history-dialog.js +++ b/src/macro_enable-history-dialog.js @@ -27,7 +27,9 @@ const SHEET_EVENTS = [ ]; game.pf1 ??= {}; -game.pf1.historyDialog ??= { observers: new Map(), renderHooks: [] }; +game.pf1.historyDialog ??= {}; +game.pf1.historyDialog.renderHooks ??= []; +game.pf1.historyDialog.observers ??= new Map(); if (game.pf1.historyDialog.renderHooks.length) { return ui.notifications.info("Log buttons already active."); @@ -51,11 +53,11 @@ function attachButtons(sheet) { for (const targetCfg of LOG_TARGETS) { const container = targetCfg.finder(sheet.element); if (!container?.length) continue; - addButton(container, sheet.actor, targetCfg.tab, `${sheet.id}-${targetCfg.key}`); + addButton(container, sheet, targetCfg.tab, `${sheet.id}-${targetCfg.key}`); } } -function addButton(container, actor, tab, observerKey) { +function addButton(container, sheet, tab, observerKey) { if (!container.length) return; if (container.find(`.${HISTORY_BUTTON_CLASS}[data-log-tab="${tab}"]`).length) return; @@ -67,18 +69,25 @@ function addButton(container, actor, tab, observerKey) { ${BUTTON_ICON} `); - button.on("click", () => openHistoryDialog(actor, tab)); + button.on("click", () => openHistoryDialog(sheet.actor, tab)); container.append(button); - observeContainer(container, actor, tab, observerKey); + observeContainer(container, sheet, tab, observerKey); } -function observeContainer(container, actor, tab, key) { +function observeContainer(container, sheet, tab, key) { if (game.pf1.historyDialog.observers.has(key)) return; - const observer = new MutationObserver(() => addButton(container, actor, tab, key)); + const observer = new MutationObserver(() => addButton(container, sheet, tab, key)); observer.observe(container[0], { childList: true }); game.pf1.historyDialog.observers.set(key, observer); + + const cleanup = () => { + observer.disconnect(); + game.pf1.historyDialog.observers.delete(key); + sheet.element.off("remove", cleanup); + }; + sheet.element.on("remove", cleanup); } function findHpContainer(root) { @@ -101,31 +110,33 @@ function findXpContainer(root) { } function findCurrencyContainer(root) { - const summaryHeader = root + const header = root .find("h3, label") .filter((_, el) => el.textContent.trim().toLowerCase() === "currency") .first(); - if (summaryHeader.length) return summaryHeader.parent(); + if (header.length) return header.parent().length ? header.parent() : header; return root.find(".currency").first(); } function openHistoryDialog(actor, initialTab = "hp") { if (!actor) return; - const content = buildHistoryContent(actor, initialTab); - new Dialog( + const content = buildHistoryContent(actor); + const dlg = new Dialog( { title: `${actor.name}: Log`, content, buttons: { close: { label: "Close" } }, + render: (html) => setupTabs(html, initialTab), }, { width: 720, classes: ["pf1-history-dialog"], } - ).render(true); + ); + dlg.render(true); } -function buildHistoryContent(actor, initialTab = "hp") { +function buildHistoryContent(actor) { const configs = [ { id: "hp", @@ -164,18 +175,14 @@ function buildHistoryContent(actor, initialTab = "hp") { ]; const tabs = configs - .map( - (cfg) => - `${cfg.label}` - ) + .map((cfg) => `${cfg.label}`) .join(""); const panels = configs .map((cfg) => { const entries = actor.getFlag("world", cfg.flag) ?? []; const table = renderHistoryTable(entries, cfg.columns, cfg.id); - const display = cfg.id === initialTab ? "block" : "none"; - return `
${table}
`; + return `
${table}
`; }) .join(""); @@ -188,22 +195,20 @@ function buildHistoryContent(actor, initialTab = "hp") {
${panels}
- `; } +function setupTabs(html, initialTab) { + const buttons = Array.from(html[0].querySelectorAll(".history-tab")); + const panels = Array.from(html[0].querySelectorAll(".history-panel")); + const activate = (target) => { + buttons.forEach((btn) => btn.classList.toggle("active", btn.dataset.historyTab === target)); + panels.forEach((panel) => (panel.style.display = panel.dataset.historyPanel === target ? "block" : "none")); + }; + buttons.forEach((btn) => btn.addEventListener("click", () => activate(btn.dataset.historyTab))); + activate(initialTab); +} + function renderHistoryTable(entries, columns, id) { if (!entries.length) { return `

No ${id.toUpperCase()} history recorded.

`; diff --git a/src/macros_new/gowlers_tracking_ledger/module.json b/src/macros_new/gowlers_tracking_ledger/module.json new file mode 100644 index 00000000..ccae17ca --- /dev/null +++ b/src/macros_new/gowlers_tracking_ledger/module.json @@ -0,0 +1,15 @@ +{ + "id": "gowlers-tracking-ledger", + "type": "module", + "title": "Gowler's Tracking Ledger", + "description": "Adds HP/XP/Currency log buttons to PF1 sheets and opens the tracking dialog preloaded with the actor's logs.", + "version": "0.1.0", + "authors": [ + { "name": "Gowler", "url": "https://foundryvtt.com" } + ], + "compatibility": { + "minimum": "11", + "verified": "11" + }, + "scripts": ["scripts/gowlers-tracking-ledger.js"] +} diff --git a/src/macros_new/gowlers_tracking_ledger/scripts/gowlers-tracking-ledger.js b/src/macros_new/gowlers_tracking_ledger/scripts/gowlers-tracking-ledger.js new file mode 100644 index 00000000..3615450a --- /dev/null +++ b/src/macros_new/gowlers_tracking_ledger/scripts/gowlers-tracking-ledger.js @@ -0,0 +1,599 @@ +(function () { + const MODULE_ID = "gowlers-tracking-ledger"; + const BUTTON_CLASS = "pf1-history-btn"; + const BUTTON_TITLE = "Open Log"; + const BUTTON_ICON = ''; + const BUTTON_STYLE = + "position:absolute;right:6px;top:4px;border:none;background:transparent;padding:0;width:18px;height:18px;display:flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;"; + + let trackingConfig = {}; + + const LOG_TARGETS = [ + { key: "hp", tab: "hp", finder: findHpContainer }, + { key: "xp", tab: "xp", finder: findXpContainer }, + { key: "currency", tab: "currency", finder: findCurrencyContainer }, + ]; + + const TRACKERS = { + hp: { + key: "hp", + flag: "pf1HpHistory", + updateFlag: "hpHistoryFlags", + maxRows: 50, + state: { current: new Map(), sources: new Map(), updateHookId: null, applyHooked: false }, + readValue: readHpValue, + cloneValue: (value) => value, + equal: (a, b) => Object.is(a, b), + buildEntry(actor, newVal, prevVal, userId) { + const diff = newVal - prevVal; + const diffText = diff >= 0 ? `+${diff}` : `${diff}`; + const user = game.users.get(userId); + const source = consumeDamageSource(actor.id) ?? inferManualSource(diff); + return { + hp: newVal, + diff: diffText, + user: user?.name ?? "System", + source: source ?? "", + }; + }, + initExtras() { + ensureDamageSourceTracking(this.state); + }, + }, + xp: { + key: "xp", + flag: "pf1XpHistory", + maxRows: 50, + state: { current: new Map(), updateHookId: null }, + readValue: readXpValue, + cloneValue: (value) => value, + equal: (a, b) => Object.is(a, b), + buildEntry(actor, newVal, prevVal, userId) { + const diff = newVal - prevVal; + const diffText = diff >= 0 ? `+${diff}` : `${diff}`; + const user = game.users.get(userId); + return { + value: newVal, + diff: diffText, + user: user?.name ?? "System", + }; + }, + }, + currency: { + key: "currency", + flag: "pf1CurrencyHistory", + maxRows: 50, + state: { current: new Map(), updateHookId: null }, + readValue: readCurrency, + cloneValue: (value) => ({ ...value }), + equal: currencyEquals, + buildEntry(actor, newVal, prevVal, userId) { + const diff = diffCurrency(prevVal, newVal); + const user = game.users.get(userId); + return { + value: formatCurrency(newVal), + diff: formatCurrency(diff, true), + user: user?.name ?? "System", + }; + }, + }, + }; + + Hooks.once("init", () => { + registerSettings(); + }); + + Hooks.once("ready", () => { + registerButtons(); + reconfigureTracking(); + }); + + function registerSettings() { + game.settings.register(MODULE_ID, "trackingConfig", { + scope: "world", + config: false, + type: Object, + default: {}, + }); + + game.settings.registerMenu(MODULE_ID, "config", { + name: "Tracking Ledger", + label: "Configure Tracking", + hint: "Choose which actors have HP/XP/Currency logs recorded.", + restricted: true, + type: TrackingLedgerConfig, + }); + } + + function reconfigureTracking() { + trackingConfig = duplicate(game.settings.get(MODULE_ID, "trackingConfig") ?? {}); + + for (const tracker of Object.values(TRACKERS)) { + tracker.initExtras?.(); + ensureUpdateHook(tracker); + + // Remove current values for actors no longer tracked. + for (const actorId of Array.from(tracker.state.current.keys())) { + if (!trackingConfig[actorId]?.[tracker.key]) tracker.state.current.delete(actorId); + } + + // Initialize tracked actors. + for (const [actorId, settings] of Object.entries(trackingConfig)) { + if (!settings?.[tracker.key]) continue; + const actor = game.actors.get(actorId); + if (!actor) continue; + const value = tracker.readValue(actor); + if (value === null || value === undefined) continue; + tracker.state.current.set(actorId, tracker.cloneValue ? tracker.cloneValue(value) : value); + } + } + } + + function ensureUpdateHook(tracker) { + if (tracker.state.updateHookId) return; + tracker.state.updateHookId = Hooks.on("updateActor", (actor, change, options, userId) => { + if (!trackingConfig[actor.id]?.[tracker.key]) return; + processTrackerChange(tracker, actor, userId); + }); + } + + async function processTrackerChange(tracker, actor, userId) { + const actorId = actor.id; + const newValue = tracker.readValue(actor); + if (newValue === null || newValue === undefined) return; + + const prev = tracker.state.current.get(actorId); + if (prev === undefined) { + tracker.state.current.set(actorId, tracker.cloneValue ? tracker.cloneValue(newValue) : newValue); + return; + } + + const isEqual = tracker.equal ? tracker.equal(prev, newValue) : Object.is(prev, newValue); + if (isEqual) return; + + tracker.state.current.set(actorId, tracker.cloneValue ? tracker.cloneValue(newValue) : newValue); + const entry = tracker.buildEntry(actor, newValue, prev, userId); + entry.timestamp = Date.now(); + await appendEntry(actor, tracker.flag, entry, tracker.maxRows); + } + + async function appendEntry(actor, flag, entry, maxRows) { + const existing = (await actor.getFlag("world", flag)) ?? []; + existing.unshift(entry); + if (existing.length > maxRows) existing.splice(maxRows); + await actor.setFlag("world", flag, existing); + } + + // ---------------- Settings Form ---------------- + + class TrackingLedgerConfig extends FormApplication { + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + id: "gowlers-tracking-ledger-config", + title: "Tracking Ledger", + template: `modules/${MODULE_ID}/templates/config.hbs`, + width: 600, + height: "auto", + }); + } + + getData() { + const config = game.settings.get(MODULE_ID, "trackingConfig") ?? {}; + const actors = game.actors.map((actor) => ({ + id: actor.id, + name: actor.name, + type: actor.type, + tracking: config[actor.id] ?? {}, + })); + return { actors }; + } + + async _updateObject(event, formData) { + const expanded = expandObject(formData); + const config = {}; + const actorsData = expanded.actors ?? {}; + + for (const [actorId, trackerFlags] of Object.entries(actorsData)) { + const entry = {}; + for (const key of Object.keys(TRACKERS)) { + if (trackerFlags[key]) entry[key] = true; + } + if (!foundry.utils.isEmpty(entry)) config[actorId] = entry; + } + + await game.settings.set(MODULE_ID, "trackingConfig", config); + reconfigureTracking(); + } + } + + // --------------- Log Buttons --------------- + + function registerButtons() { + const state = ensureButtonState(); + if (state.renderHooks.length) return; + + const sheetEvents = [ + "renderActorSheetPFCharacter", + "renderActorSheetPFNPC", + "renderActorSheetPFNPCLoot", + "renderActorSheetPFNPCLite", + "renderActorSheetPFTrap", + "renderActorSheetPFVehicle", + "renderActorSheetPFHaunt", + "renderActorSheetPFBasic", + ]; + + state.renderHooks = sheetEvents.map((event) => { + const id = Hooks.on(event, (sheet) => { + const delays = [0, 100, 250]; + delays.forEach((delay) => setTimeout(() => attachButtons(sheet, state), delay)); + }); + return { event, id }; + }); + } + + function ensureButtonState() { + game.pf1 ??= {}; + game.pf1[`${MODULE_ID}-buttons`] ??= { renderHooks: [], observers: new Map() }; + return game.pf1[`${MODULE_ID}-buttons`]; + } + + function attachButtons(sheet, state) { + for (const targetCfg of LOG_TARGETS) { + const container = targetCfg.finder(sheet.element); + if (!container?.length) continue; + addButton(container, sheet, targetCfg.tab, `${sheet.id}-${targetCfg.key}`, state); + } + } + + function addButton(container, sheet, tab, observerKey, state) { + if (!container.length) return; + if (container.find(`.${BUTTON_CLASS}[data-log-tab="${tab}"]`).length) return; + + if (container.css("position") === "static") container.css("position", "relative"); + + const button = $(` + + `); + button.on("click", () => openHistoryDialog(sheet.actor, tab)); + container.append(button); + + observeContainer(container, sheet, tab, observerKey, state); + } + + function observeContainer(container, sheet, tab, key, state) { + if (state.observers.has(key)) return; + + const observer = new MutationObserver(() => addButton(container, sheet, tab, key, state)); + observer.observe(container[0], { childList: true }); + state.observers.set(key, observer); + + const cleanup = () => { + observer.disconnect(); + state.observers.delete(key); + sheet.element.off("remove", cleanup); + }; + sheet.element.on("remove", cleanup); + } + + function findHpContainer(root) { + const header = root + .find("h3, label") + .filter((_, el) => el.textContent.trim() === "Hit Points") + .first(); + if (!header.length) return null; + return header.parent().length ? header.parent() : header.closest(".attribute.hitpoints").first(); + } + + function findXpContainer(root) { + const box = root.find(".info-box.experience").first(); + if (box.length) return box; + const header = root + .find("h5, label") + .filter((_, el) => el.textContent.trim().toLowerCase() === "experience") + .first(); + return header.length ? (header.parent().length ? header.parent() : header) : null; + } + + function findCurrencyContainer(root) { + const header = root + .find("h3, label") + .filter((_, el) => el.textContent.trim().toLowerCase() === "currency") + .first(); + if (header.length) return header.parent().length ? header.parent() : header; + return root.find(".currency").first(); + } + + function openHistoryDialog(actor, initialTab = "hp") { + if (!actor) return; + const content = buildHistoryContent(actor); + const dlg = new Dialog( + { + title: `${actor.name}: Log`, + content, + buttons: { close: { label: "Close" } }, + render: (html) => setupTabs(html, initialTab), + }, + { + width: 720, + classes: ["pf1-history-dialog"], + } + ); + dlg.render(true); + } + + function buildHistoryContent(actor) { + const configs = [ + { + id: "hp", + label: "HP", + flag: "pf1HpHistory", + columns: [ + { label: "Timestamp", render: (e) => formatDate(e.timestamp) }, + { label: "HP", render: (e) => e.hp }, + { label: "Δ", render: (e) => e.diff }, + { label: "User", render: (e) => e.user ?? "" }, + { label: "Source", render: (e) => e.source ?? "" }, + ], + }, + { + id: "xp", + label: "XP", + flag: "pf1XpHistory", + columns: [ + { label: "Timestamp", render: (e) => formatDate(e.timestamp) }, + { label: "XP", render: (e) => e.value }, + { label: "Δ", render: (e) => e.diff }, + { label: "User", render: (e) => e.user ?? "" }, + ], + }, + { + id: "currency", + label: "Currency", + flag: "pf1CurrencyHistory", + columns: [ + { label: "Timestamp", render: (e) => formatDate(e.timestamp) }, + { label: "Totals", render: (e) => e.value }, + { label: "Δ", render: (e) => e.diff }, + { label: "User", render: (e) => e.user ?? "" }, + ], + }, + ]; + + const tabs = configs + .map((cfg) => `${cfg.label}`) + .join(""); + + const panels = configs + .map((cfg) => { + const entries = actor.getFlag("world", cfg.flag) ?? []; + const table = renderHistoryTable(entries, cfg.columns, cfg.id); + return `
${table}
`; + }) + .join(""); + + return ` +
+ + +
${panels}
+
`; + } + + function setupTabs(html, initialTab = "hp") { + const buttons = Array.from(html[0].querySelectorAll(".history-tab")); + const panels = Array.from(html[0].querySelectorAll(".history-panel")); + const activate = (target) => { + buttons.forEach((btn) => btn.classList.toggle("active", btn.dataset.historyTab === target)); + panels.forEach((panel) => (panel.style.display = panel.dataset.historyPanel === target ? "block" : "none")); + }; + buttons.forEach((btn) => btn.addEventListener("click", () => activate(btn.dataset.historyTab))); + activate(initialTab); + } + + function renderHistoryTable(entries, columns, id) { + if (!entries.length) { + return `

No ${id.toUpperCase()} history recorded.

`; + } + + const rows = entries + .map( + (entry) => ` + + ${columns.map((col) => `${col.render(entry) ?? ""}`).join("")} + ` + ) + .join(""); + + return ` + + + ${columns.map((col) => ``).join("")} + + + ${rows} + +
${col.label}
`; + } + + function formatDate(ts) { + return ts ? new Date(ts).toLocaleString() : ""; + } + + // --------- HP Helpers ---------- + + function readHpValue(actor) { + if (!actor) return null; + const value = foundry.utils.getProperty(actor, "system.attributes.hp.value"); + if (value === undefined) return null; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; + } + + function consumeDamageSource(actorId) { + const tracker = TRACKERS.hp; + const entry = tracker.state.sources.get(actorId); + if (!entry) return null; + tracker.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(state) { + if (state.applyHooked) 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, state); + } catch (err) { + console.warn("Tracking Ledger | Failed to record damage source", err); + } + return original.call(this, value, options); + }; + + state.applyHooked = true; + } + + function noteDamageSource(value, options, state) { + const actors = resolveActorTargets(options?.targets); + for (const actor of actors) { + const label = buildSourceLabel(value, options, actor); + if (!label) continue; + state.sources.set(actor.id, { label, ts: Date.now() }); + } + } + + function buildSourceLabel(value, options, actor) { + const info = options?.message?.flags?.pf1?.identifiedInfo ?? {}; + const metadata = options?.message?.flags?.pf1?.metadata ?? {}; + const fromChatFlavor = options?.message?.flavor?.trim(); + + const actorDoc = resolveActorFromMetadata(metadata) ?? actor; + 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 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 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("Tracking Ledger | 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; + } + + // --------- XP Helpers ---------- + + function readXpValue(actor) { + if (!actor) return null; + const direct = foundry.utils.getProperty(actor, "system.details.xp.value"); + if (direct !== undefined) { + const numeric = Number(direct); + return Number.isFinite(numeric) ? numeric : null; + } + const sys = foundry.utils.getProperty(actor.system ?? {}, "details.xp.value"); + if (sys !== undefined) { + const numeric = Number(sys); + return Number.isFinite(numeric) ? numeric : null; + } + return null; + } + + // --------- Currency Helpers ---------- + + const COIN_ORDER = ["pp", "gp", "sp", "cp"]; + + function readCurrency(actor) { + if (!actor) return null; + const base = foundry.utils.getProperty(actor, "system.currency") ?? 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}`; + return `${label}:${v}`; + }) + .filter(Boolean) + .join(" "); + } + + // -------- Settings Template Helper -------- + + function formatDate(ts) { + return ts ? new Date(ts).toLocaleString() : ""; + } + + // Expose API for debugging if needed. + game.modules.get(MODULE_ID).api = { reconfigureTracking }; +})(); diff --git a/src/macros_new/gowlers_tracking_ledger/templates/config.hbs b/src/macros_new/gowlers_tracking_ledger/templates/config.hbs new file mode 100644 index 00000000..3bcd332b --- /dev/null +++ b/src/macros_new/gowlers_tracking_ledger/templates/config.hbs @@ -0,0 +1,31 @@ +
+ + + + + + + + + + + {{#each actors}} + + + + + + + {{/each}} + +
ActorHPXPCurrency
{{name}} + + + + + +
+ +