working
This commit is contained in:
@@ -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>
|
||||
`);
|
||||
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) =>
|
||||
`<a class="history-tab ${cfg.id === initialTab ? "active" : ""}" data-history-tab="${cfg.id}">${cfg.label}</a>`
|
||||
)
|
||||
.map((cfg) => `<a class="history-tab" data-history-tab="${cfg.id}">${cfg.label}</a>`)
|
||||
.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 `<section class="history-panel" data-history-panel="${cfg.id}" style="display:${display}">${table}</section>`;
|
||||
return `<section class="history-panel" data-history-panel="${cfg.id}">${table}</section>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
@@ -188,22 +195,20 @@ function buildHistoryContent(actor, initialTab = "hp") {
|
||||
</style>
|
||||
<nav class="history-dialog-tabs">${tabs}</nav>
|
||||
<div class="history-dialog-panels">${panels}</div>
|
||||
<script type="text/javascript">
|
||||
(function(){
|
||||
const root = document.currentScript.closest('[data-history-root="${actor.id}"]');
|
||||
if (!root) return;
|
||||
const buttons = Array.from(root.querySelectorAll('.history-tab'));
|
||||
const panels = Array.from(root.querySelectorAll('.history-panel'));
|
||||
function 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)));
|
||||
})();
|
||||
</script>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
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 `<p class="history-empty">No ${id.toUpperCase()} history recorded.</p>`;
|
||||
|
||||
15
src/macros_new/gowlers_tracking_ledger/module.json
Normal file
15
src/macros_new/gowlers_tracking_ledger/module.json
Normal file
@@ -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"]
|
||||
}
|
||||
@@ -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 = '<i class="fas fa-clock-rotate-left"></i>';
|
||||
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 type="button" class="${BUTTON_CLASS}" data-log-tab="${tab}"
|
||||
title="${BUTTON_TITLE}" style="${BUTTON_STYLE}">
|
||||
${BUTTON_ICON}
|
||||
</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) => `<a class="history-tab" data-history-tab="${cfg.id}">${cfg.label}</a>`)
|
||||
.join("");
|
||||
|
||||
const panels = configs
|
||||
.map((cfg) => {
|
||||
const entries = actor.getFlag("world", cfg.flag) ?? [];
|
||||
const table = renderHistoryTable(entries, cfg.columns, cfg.id);
|
||||
return `<section class="history-panel" data-history-panel="${cfg.id}">${table}</section>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<section class="history-dialog-root" data-history-root="${actor.id}">
|
||||
<style>
|
||||
.history-dialog-tabs { display:flex; gap:6px; margin-bottom:8px; }
|
||||
.history-tab { flex:1; text-align:center; padding:4px 0; border:1px solid #b5b3a4; border-radius:3px; cursor:pointer; background:#ddd; }
|
||||
.history-tab.active { background:#fff; font-weight:bold; }
|
||||
</style>
|
||||
<nav class="history-dialog-tabs">${tabs}</nav>
|
||||
<div class="history-dialog-panels">${panels}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
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 `<p class="history-empty">No ${id.toUpperCase()} history recorded.</p>`;
|
||||
}
|
||||
|
||||
const rows = entries
|
||||
.map(
|
||||
(entry) => `
|
||||
<tr>
|
||||
${columns.map((col) => `<td>${col.render(entry) ?? ""}</td>`).join("")}
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<table class="history-table history-${id}">
|
||||
<thead>
|
||||
<tr>${columns.map((col) => `<th>${col.label}</th>`).join("")}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
31
src/macros_new/gowlers_tracking_ledger/templates/config.hbs
Normal file
31
src/macros_new/gowlers_tracking_ledger/templates/config.hbs
Normal file
@@ -0,0 +1,31 @@
|
||||
<form class="tracking-ledger-config">
|
||||
<table class="tracking-ledger-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Actor</th>
|
||||
<th>HP</th>
|
||||
<th>XP</th>
|
||||
<th>Currency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each actors}}
|
||||
<tr>
|
||||
<td>{{name}}</td>
|
||||
<td>
|
||||
<input type="checkbox" name="actors.{{id}}.hp" {{#if tracking.hp}}checked{{/if}}>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="actors.{{id}}.xp" {{#if tracking.xp}}checked{{/if}}>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="actors.{{id}}.currency" {{#if tracking.currency}}checked{{/if}}>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
<footer>
|
||||
<button type="submit"><i class="fas fa-save"></i> Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
Reference in New Issue
Block a user