working
This commit is contained in:
@@ -27,7 +27,9 @@ const SHEET_EVENTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
game.pf1 ??= {};
|
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) {
|
if (game.pf1.historyDialog.renderHooks.length) {
|
||||||
return ui.notifications.info("Log buttons already active.");
|
return ui.notifications.info("Log buttons already active.");
|
||||||
@@ -51,11 +53,11 @@ function attachButtons(sheet) {
|
|||||||
for (const targetCfg of LOG_TARGETS) {
|
for (const targetCfg of LOG_TARGETS) {
|
||||||
const container = targetCfg.finder(sheet.element);
|
const container = targetCfg.finder(sheet.element);
|
||||||
if (!container?.length) continue;
|
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.length) return;
|
||||||
if (container.find(`.${HISTORY_BUTTON_CLASS}[data-log-tab="${tab}"]`).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_ICON}
|
||||||
</button>
|
</button>
|
||||||
`);
|
`);
|
||||||
button.on("click", () => openHistoryDialog(actor, tab));
|
button.on("click", () => openHistoryDialog(sheet.actor, tab));
|
||||||
container.append(button);
|
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;
|
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 });
|
observer.observe(container[0], { childList: true });
|
||||||
game.pf1.historyDialog.observers.set(key, observer);
|
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) {
|
function findHpContainer(root) {
|
||||||
@@ -101,31 +110,33 @@ function findXpContainer(root) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findCurrencyContainer(root) {
|
function findCurrencyContainer(root) {
|
||||||
const summaryHeader = root
|
const header = root
|
||||||
.find("h3, label")
|
.find("h3, label")
|
||||||
.filter((_, el) => el.textContent.trim().toLowerCase() === "currency")
|
.filter((_, el) => el.textContent.trim().toLowerCase() === "currency")
|
||||||
.first();
|
.first();
|
||||||
if (summaryHeader.length) return summaryHeader.parent();
|
if (header.length) return header.parent().length ? header.parent() : header;
|
||||||
return root.find(".currency").first();
|
return root.find(".currency").first();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openHistoryDialog(actor, initialTab = "hp") {
|
function openHistoryDialog(actor, initialTab = "hp") {
|
||||||
if (!actor) return;
|
if (!actor) return;
|
||||||
const content = buildHistoryContent(actor, initialTab);
|
const content = buildHistoryContent(actor);
|
||||||
new Dialog(
|
const dlg = new Dialog(
|
||||||
{
|
{
|
||||||
title: `${actor.name}: Log`,
|
title: `${actor.name}: Log`,
|
||||||
content,
|
content,
|
||||||
buttons: { close: { label: "Close" } },
|
buttons: { close: { label: "Close" } },
|
||||||
|
render: (html) => setupTabs(html, initialTab),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
width: 720,
|
width: 720,
|
||||||
classes: ["pf1-history-dialog"],
|
classes: ["pf1-history-dialog"],
|
||||||
}
|
}
|
||||||
).render(true);
|
);
|
||||||
|
dlg.render(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHistoryContent(actor, initialTab = "hp") {
|
function buildHistoryContent(actor) {
|
||||||
const configs = [
|
const configs = [
|
||||||
{
|
{
|
||||||
id: "hp",
|
id: "hp",
|
||||||
@@ -164,18 +175,14 @@ function buildHistoryContent(actor, initialTab = "hp") {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const tabs = configs
|
const tabs = configs
|
||||||
.map(
|
.map((cfg) => `<a class="history-tab" data-history-tab="${cfg.id}">${cfg.label}</a>`)
|
||||||
(cfg) =>
|
|
||||||
`<a class="history-tab ${cfg.id === initialTab ? "active" : ""}" data-history-tab="${cfg.id}">${cfg.label}</a>`
|
|
||||||
)
|
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
const panels = configs
|
const panels = configs
|
||||||
.map((cfg) => {
|
.map((cfg) => {
|
||||||
const entries = actor.getFlag("world", cfg.flag) ?? [];
|
const entries = actor.getFlag("world", cfg.flag) ?? [];
|
||||||
const table = renderHistoryTable(entries, cfg.columns, cfg.id);
|
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}">${table}</section>`;
|
||||||
return `<section class="history-panel" data-history-panel="${cfg.id}" style="display:${display}">${table}</section>`;
|
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
@@ -188,22 +195,20 @@ function buildHistoryContent(actor, initialTab = "hp") {
|
|||||||
</style>
|
</style>
|
||||||
<nav class="history-dialog-tabs">${tabs}</nav>
|
<nav class="history-dialog-tabs">${tabs}</nav>
|
||||||
<div class="history-dialog-panels">${panels}</div>
|
<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>`;
|
</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) {
|
function renderHistoryTable(entries, columns, id) {
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
return `<p class="history-empty">No ${id.toUpperCase()} history recorded.</p>`;
|
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