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 `
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 `No ${id.toUpperCase()} history recorded.
`; + } + + const rows = entries + .map( + (entry) => ` +| ${col.label} | `).join("")}
|---|