This commit is contained in:
centron\schwoerer
2025-11-19 11:47:02 +01:00
parent 33aebf2b87
commit e71d54659e
3 changed files with 1026 additions and 37 deletions

View File

@@ -0,0 +1,995 @@
const MODULE_ID = "gowlers-tracking-ledger";
const MODULE_VERSION = "0.1.3";
const TRACK_SETTING = "actorSettings";
const FLAG_SCOPE = "world";
const MAX_HISTORY_ROWS = 100;
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;";
const DEFAULT_TRACKING = Object.freeze({ hp: true, xp: true, currency: true });
const DEFAULT_CHAT = Object.freeze({ hp: false, xp: false, currency: false });
const SETTINGS_VERSION = 2;
const COIN_ORDER = ["pp", "gp", "sp", "cp"];
const ENCOUNTER_FLAG = "pf1EncounterHistory";
const STAT_CONFIGS = {
hp: {
id: "hp",
label: "HP",
flag: "pf1HpHistory",
getter: (actor) => readNumericProperty(actor, "system.attributes.hp.value"),
equals: (a, b) => Object.is(a, b),
diff: (prev, next) => next - prev,
formatValue: (value) => `${value}`,
formatDiff: (diff) => (diff >= 0 ? `+${diff}` : `${diff}`),
clone: (value) => value,
},
xp: {
id: "xp",
label: "XP",
flag: "pf1XpHistory",
getter: (actor) => readNumericProperty(actor, "system.details.xp.value"),
equals: (a, b) => Object.is(a, b),
diff: (prev, next) => next - prev,
formatValue: (value) => `${value}`,
formatDiff: (diff) => (diff >= 0 ? `+${diff}` : `${diff}`),
clone: (value) => value,
},
currency: {
id: "currency",
label: "Currency",
flag: "pf1CurrencyHistory",
getter: (actor) => readCurrency(actor),
equals: currencyEquals,
diff: diffCurrency,
formatValue: (value) => formatCurrency(value, false),
formatDiff: (value) => formatCurrency(value, true),
clone: (value) => (value ? { ...value } : value),
},
};
const LOG_TARGETS = [
{ id: "hp", finder: findHpHeader },
{ id: "xp", finder: findXpHeader },
{ id: "currency", finder: findCurrencyHeader },
];
const SHEET_EVENTS = [
"renderActorSheetPFCharacter",
"renderActorSheetPFNPC",
"renderActorSheetPFNPCLoot",
"renderActorSheetPFNPCLite",
"renderActorSheetPFTrap",
"renderActorSheetPFVehicle",
"renderActorSheetPFHaunt",
"renderActorSheetPFBasic",
];
const ledgerState = {
baselines: new Map(), // actorId -> { hp, xp, currency }
sheetObservers: new Map(), // `${sheetId}-${stat}` -> observer
sheetHooks: [],
updateHook: null,
createHook: null,
deleteHook: null,
actorSettings: null,
};
Hooks.once("init", () => {
if (game.system.id !== "pf1") return;
registerSettings();
registerSettingsMenu();
});
Hooks.once("ready", async () => {
if (game.system.id !== "pf1") return;
await initializeModule();
});
async function initializeModule() {
if (globalThis.GowlersTrackingLedger?.initialized) return;
await primeAllActors();
ledgerState.updateHook = Hooks.on("updateActor", handleActorUpdate);
ledgerState.createHook = Hooks.on("createActor", handleCreateActor);
ledgerState.deleteHook = Hooks.on("deleteActor", (actor) => ledgerState.baselines.delete(actor.id));
ledgerState.sheetHooks = SHEET_EVENTS.map((event) =>
Hooks.on(event, (sheet) => {
const delays = [0, 100, 250];
delays.forEach((delay) => setTimeout(() => attachButtons(sheet), delay));
})
);
// Track combat encounters
Hooks.on("combatStart", (combat) => onCombatStart(combat));
Hooks.on("combatEnd", (combat) => onCombatEnd(combat));
Hooks.on("updateCombat", (combat) => onCombatUpdate(combat));
const api = {
initialized: true,
openConfig: () => new TrackingLedgerConfig().render(true),
openConfigForActor: (actorId) => TrackingLedgerConfig.openForActor(actorId),
setActorTracking: setActorTracking,
getActorTracking,
};
game.modules.get(MODULE_ID).api = api;
globalThis.GowlersTrackingLedger = api;
}
function registerSettings() {
game.settings.register(MODULE_ID, TRACK_SETTING, {
scope: "world",
config: false,
type: Object,
default: {},
});
}
function registerSettingsMenu() {
game.settings.registerMenu(MODULE_ID, "config", {
name: "Gowler's Tracking Ledger",
label: "Configure Tracking",
hint: "Choose which actors should track HP/XP/Currency logs.",
type: TrackingLedgerConfig,
restricted: true,
icon: "fas fa-book",
});
}
async function handleCreateActor(actor) {
await ensureActorConfig(actor);
primeActor(actor);
}
function handleActorUpdate(actor, change, options, userId) {
if (options?.[MODULE_ID]) return;
if (!(actor instanceof Actor)) return;
const tracking = getActorTracking(actor.id);
if (!tracking) return;
const baselines = ledgerState.baselines.get(actor.id) ?? {};
let changed = false;
for (const statId of Object.keys(STAT_CONFIGS)) {
if (!tracking[statId]) continue;
const config = STAT_CONFIGS[statId];
const nextValue = config.getter(actor);
if (nextValue === null || nextValue === undefined) continue;
if (baselines[statId] === undefined) {
baselines[statId] = config.clone(nextValue);
continue;
}
if (config.equals(baselines[statId], nextValue)) continue;
const previous = baselines[statId];
baselines[statId] = config.clone(nextValue);
changed = true;
recordHistoryEntry(actor, statId, previous, nextValue, userId).catch((err) =>
console.error(`Tracking Ledger | Failed to append ${statId} entry`, err)
);
}
if (changed) ledgerState.baselines.set(actor.id, baselines);
}
async function primeAllActors() {
const settings = getSettingsCache();
let dirty = false;
for (const actor of game.actors.contents) {
if (!settings[actor.id]) {
settings[actor.id] = createActorConfig();
dirty = true;
}
primeActor(actor);
}
if (dirty) {
await saveActorSettings(settings);
}
}
function primeActor(actor, stats = Object.keys(STAT_CONFIGS)) {
if (!(actor instanceof Actor)) return;
const existing = ledgerState.baselines.get(actor.id) ?? {};
for (const statId of stats) {
const config = STAT_CONFIGS[statId];
const value = config.getter(actor);
if (value === null || value === undefined) continue;
existing[statId] = config.clone(value);
}
ledgerState.baselines.set(actor.id, existing);
}
function attachButtons(sheet) {
if (!sheet?.element?.length || !sheet.actor) return;
for (const target of LOG_TARGETS) {
try {
const container = target.finder(sheet.element);
if (!container?.length) continue;
addButton(container, sheet, target.id);
} catch (err) {
console.warn(`Tracking Ledger | Failed to add ${target.id} button`, err);
}
}
}
function addButton(container, sheet, statId) {
if (!container || !container.length || !sheet.actor) return;
if (container.find(`.${BUTTON_CLASS}[data-log-target="${statId}"]`).length) return;
if (container.css("position") === "static") container.css("position", "relative");
const label = STAT_CONFIGS[statId]?.label ?? statId.toUpperCase();
const button = $(`
<button type="button" class="${BUTTON_CLASS}" data-log-target="${statId}" title="${BUTTON_TITLE} (${label})"
aria-label="${BUTTON_TITLE} (${label})" style="${BUTTON_STYLE}">
${BUTTON_ICON}
</button>
`);
button.on("click", (event) => {
event.preventDefault();
event.stopPropagation();
openHistoryDialog(sheet.actor, statId);
});
container.append(button);
observeContainer(container, sheet, statId);
}
function observeContainer(container, sheet, statId) {
const key = `${sheet.id}-${statId}`;
if (ledgerState.sheetObservers.has(key)) return;
if (!container[0]) return;
const observer = new MutationObserver(() => addButton(container, sheet, statId));
observer.observe(container[0], { childList: true });
ledgerState.sheetObservers.set(key, observer);
const cleanup = () => {
observer.disconnect();
ledgerState.sheetObservers.delete(key);
sheet.element.off("remove", cleanup);
};
sheet.element.on("remove", cleanup);
}
function findHpHeader(root) {
let header = root.find(".info-box-header.health-details h3").first();
if (header.length) return header;
header = root.find('[data-tooltip-extended="hit-points"] h3').first();
if (header.length) return header;
return findHeaderByText(root, "Hit Points");
}
function findXpHeader(root) {
const experienceHeader = root.find(".info-box.experience h5").first();
if (experienceHeader.length) return experienceHeader;
return findHeaderByText(root, "Experience");
}
function findCurrencyHeader(root) {
const currencySection = root.find('.tab.inventory ol.currency h3').first();
if (currencySection.length) return currencySection;
return findHeaderByText(root, "Currency");
}
function findHeaderByText(root, text) {
const lower = text.toLowerCase();
return root
.find("h1, h2, h3, h4, h5, label")
.filter((_, el) => el.textContent?.trim().toLowerCase() === lower)
.first();
}
function openHistoryDialog(actor, initialTab = "hp") {
if (!actor) return;
const content = buildHistoryContent(actor, initialTab);
const dialog = new Dialog(
{
title: `${actor.name}: Tracking Log`,
content,
buttons: { close: { label: "Close" } },
},
{
width: 800,
height: "auto",
classes: ["pf1-history-dialog"],
render: (html) => {
// Handle both jQuery objects and DOM elements
const $html = $(html);
const $root = $html.find('[data-history-root]');
if (!$root.length) {
console.warn("[GowlersTracking] Root not found");
return;
}
// Tab switching
$root.find('[data-history-tab]').off('click').on('click', function(e) {
e.preventDefault();
const tabId = $(this).attr('data-history-tab');
console.log("[GowlersTracking] Tab clicked:", tabId);
// Remove active from all tabs and hide all panels
$root.find('[data-history-tab]').removeClass('active');
$root.find('[data-history-panel]').hide();
// Add active to clicked tab and show its panel
$root.find(`[data-history-tab="${tabId}"]`).addClass('active');
$root.find(`[data-history-panel="${tabId}"]`).show();
});
// Add config icon to dialog header (GM only)
if (game.user?.isGM) {
const $header = $html.closest('.dialog').find('.dialog-header');
if ($header.length && !$header.find('[data-history-config-header]').length) {
const $configBtn = $('<button/>', {
type: 'button',
class: 'history-config-header-btn',
'data-history-config-header': 'true',
title: 'Configure Actor Tracking',
html: '<i class="fas fa-cog"></i>',
css: {
border: 'none',
background: 'transparent',
color: '#999',
cursor: 'pointer',
padding: '4px 8px',
marginRight: '4px',
fontSize: '18px',
transition: 'color 0.2s'
}
});
$configBtn.on('mouseenter', function() {
$(this).css('color', '#333');
}).on('mouseleave', function() {
$(this).css('color', '#999');
}).on('click', function(e) {
e.preventDefault();
const api = window.GowlersTrackingLedger;
if (api?.openConfigForActor) {
api.openConfigForActor(actor.id);
} else if (api?.openConfig) {
api.openConfig();
}
});
$header.find('.close').before($configBtn);
console.log("[GowlersTracking] Config button added");
}
}
},
}
);
dialog.render(true);
}
function buildHistoryContent(actor, tabArg) {
const initialTab = tabArg ?? "hp"; // Explicitly capture the tab parameter
console.log("[GowlersTracking] buildHistoryContent called with initialTab:", initialTab);
const canConfigure = game.user?.isGM;
const configs = [
{
id: "hp",
label: "HP",
flag: STAT_CONFIGS.hp.flag,
columns: [
{ label: "Timestamp", render: (entry) => formatDate(entry.timestamp) },
{ label: "HP", render: (entry) => entry.value },
{ label: "Δ", render: (entry) => entry.diff },
{ label: "User", render: (entry) => entry.user ?? "" },
{ label: "Encounter", render: (entry) => entry.encounterId ? entry.encounterId.slice(0, 8) : "N/A" },
],
},
{
id: "xp",
label: "XP",
flag: STAT_CONFIGS.xp.flag,
columns: [
{ label: "Timestamp", render: (entry) => formatDate(entry.timestamp) },
{ label: "XP", render: (entry) => entry.value },
{ label: "Δ", render: (entry) => entry.diff },
{ label: "User", render: (entry) => entry.user ?? "" },
{ label: "Encounter", render: (entry) => entry.encounterId ? entry.encounterId.slice(0, 8) : "N/A" },
],
},
{
id: "currency",
label: "Currency",
flag: STAT_CONFIGS.currency.flag,
columns: [
{ label: "Timestamp", render: (entry) => formatDate(entry.timestamp) },
{ label: "Totals", render: (entry) => entry.value },
{ label: "Δ", render: (entry) => entry.diff },
{ label: "User", render: (entry) => entry.user ?? "" },
{ label: "Encounter", render: (entry) => entry.encounterId ? entry.encounterId.slice(0, 8) : "N/A" },
],
},
{
id: "encounters",
label: "Encounters",
flag: ENCOUNTER_FLAG,
columns: [
{ label: "Encounter ID", render: (entry) => entry.encounterID.slice(0, 8) },
{ label: "Date", render: (entry) => formatDate(entry.dateCreated) },
{ label: "Status", render: (entry) => entry.status ?? "unknown" },
{ label: "Rounds", render: (entry) => entry.rounds || 0 },
{ label: "Participants", render: (entry) => entry.participants ? entry.participants.length : 0 },
],
},
];
const tabs = configs
.map(
(cfg) => {
const isActive = cfg.id === initialTab ? "active" : "";
return `<a class="item history-tab-link ${isActive}" data-history-tab="${cfg.id}">${cfg.label}</a>`;
}
)
.join("");
const panels = configs
.map((cfg) => {
const entries = actor.getFlag(FLAG_SCOPE, cfg.flag) ?? [];
const display = cfg.id === initialTab ? "block" : "none";
const isActive = cfg.id === initialTab ? "active" : "";
return `<div class="history-panel tab ${isActive}" data-history-panel="${cfg.id}" style="display:${display}">
${renderHistoryTable(entries, cfg.columns, cfg.id)}
</div>`;
})
.join("");
return `
<section class="history-dialog-root" data-history-root="${actor.id}">
<style>
.history-dialog-tabs { display:flex; gap:0; border-bottom:1px solid #b5b3a4; margin-bottom:0; }
.history-dialog-tabs .item { flex:1; text-align:center; padding:6px 8px; cursor:pointer; border:1px solid #b5b3a4; border-bottom:none; background:#d6d3c8; font-weight:bold; text-transform:uppercase; font-size:0.9em; }
.history-dialog-tabs .item:not(.active) { opacity:0.75; }
.history-dialog-tabs .item:first-child { border-top-left-radius:6px; }
.history-dialog-tabs .item:last-child { border-top-right-radius:6px; }
.history-dialog-tabs .item.active { background:#fff; opacity:1; position:relative; top:1px; cursor:pointer; }
.history-dialog-panels { border:1px solid #b5b3a4; border-top:none; padding:8px; border-radius:0 6px 6px 6px; background:#fff; min-height: 200px; }
.history-table { width:100%; border-collapse:collapse; }
.history-table th, .history-table td { border:1px solid #b5b3a4; padding:4px; text-align:left; }
.history-empty { font-style:italic; color: #999; }
.history-dialog-footer { font-size: 0.8em; color: #999; text-align: center; margin-top: 8px; padding-top: 8px; border-top: 1px solid #e0e0e0; }
</style>
<nav class="history-dialog-tabs">${tabs}</nav>
<div class="history-dialog-panels">${panels}</div>
<div class="history-dialog-footer">
Gowler's Tracking Ledger v${MODULE_VERSION}
</div>
</section>`;
}
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>`;
}
async function recordHistoryEntry(actor, statId, previous, nextValue, userId) {
const config = STAT_CONFIGS[statId];
if (!config) return;
const diffValue = config.diff(previous, nextValue);
const entry = {
timestamp: Date.now(),
value: config.formatValue(nextValue),
diff: config.formatDiff(diffValue),
user: game.users.get(userId)?.name ?? "System",
source: "",
encounterId: game.combat?.id ?? null, // Track which encounter this change occurred in
};
const existing = (await actor.getFlag(FLAG_SCOPE, config.flag)) ?? [];
existing.unshift(entry);
if (existing.length > MAX_HISTORY_ROWS) existing.splice(MAX_HISTORY_ROWS);
await actor.update({ [`flags.${FLAG_SCOPE}.${config.flag}`]: existing }, { [MODULE_ID]: true });
if (shouldSendChat(actor.id, statId)) {
sendChatNotification(statId, actor, previous, nextValue, entry);
}
}
function formatDate(ts) {
return ts ? new Date(ts).toLocaleString() : "";
}
function readNumericProperty(actor, path) {
const value = foundry.utils.getProperty(actor, path) ?? foundry.utils.getProperty(actor.system ?? {}, path.replace(/^system\./, ""));
if (value === undefined || value === null) return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function readCurrency(actor) {
const base =
foundry.utils.getProperty(actor, "system.currency") ??
foundry.utils.getProperty(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) {
if (!a || !b) return false;
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 = {}, signed = false) {
return COIN_ORDER.map((coin) => {
const value = Number(obj[coin] ?? 0);
if (signed && value === 0) return null;
const formatted = signed ? (value > 0 ? `+${value}` : `${value}`) : `${value}`;
return `${coin}:${formatted}`;
})
.filter(Boolean)
.join(" ");
}
function checkboxValue(value, fallback = false) {
if (value === undefined || value === null) return fallback;
if (Array.isArray(value)) value = value[value.length - 1];
if (typeof value === "string") {
const normalized = value.toLowerCase();
if (["", "false", "0", "off", "no"].includes(normalized)) return false;
if (["true", "1", "on", "yes"].includes(normalized)) return true;
}
return Boolean(value);
}
function createActorConfig(source = null) {
const config = {
version: SETTINGS_VERSION,
tracking: { ...DEFAULT_TRACKING },
chat: { ...DEFAULT_CHAT },
};
if (!source) return config;
if (source.version === SETTINGS_VERSION) {
config.tracking = { ...config.tracking, ...(source.tracking ?? {}) };
config.chat = { ...config.chat, ...(source.chat ?? {}) };
return config;
}
// Legacy/bugged data: only preserve explicit true values so defaults re-enable tracking/chat
if (source.tracking) {
for (const [key, value] of Object.entries(source.tracking)) {
if (value === true) config.tracking[key] = true;
}
}
if (source.chat) {
for (const [key, value] of Object.entries(source.chat)) {
if (value === true) config.chat[key] = true;
}
}
return config;
}
function loadActorSettings() {
const stored = foundry.utils.deepClone(game.settings.get(MODULE_ID, TRACK_SETTING) ?? {});
for (const [actorId, cfg] of Object.entries(stored)) {
stored[actorId] = createActorConfig(cfg);
}
return stored;
}
function getSettingsCache() {
if (!ledgerState.actorSettings) {
ledgerState.actorSettings = loadActorSettings();
}
return ledgerState.actorSettings;
}
async function saveActorSettings(settings) {
for (const entry of Object.values(settings)) {
if (!entry) continue;
entry.version = SETTINGS_VERSION;
entry.tracking ??= { ...DEFAULT_TRACKING };
entry.chat ??= { ...DEFAULT_CHAT };
}
ledgerState.actorSettings = settings;
await game.settings.set(MODULE_ID, TRACK_SETTING, settings);
}
function getActorConfig(actorId) {
const settings = getSettingsCache();
if (!settings[actorId]) {
settings[actorId] = createActorConfig();
saveActorSettings(settings).catch((err) => console.error("Tracking Ledger | Failed to persist actor config", err));
}
return settings[actorId];
}
function getActorTracking(actorId) {
const entry = getActorConfig(actorId);
return { ...entry.tracking };
}
function getActorChat(actorId) {
const entry = getActorConfig(actorId);
return { ...entry.chat };
}
async function ensureActorConfig(actor) {
if (!actor?.id) return;
const settings = getSettingsCache();
if (!settings[actor.id]) {
settings[actor.id] = createActorConfig();
await saveActorSettings(settings);
}
return settings[actor.id];
}
async function setActorTracking(actorId, partial) {
if (!actorId) return;
const settings = getSettingsCache();
const entry = getActorConfig(actorId);
entry.tracking = {
hp: partial.hp ?? entry.tracking.hp,
xp: partial.xp ?? entry.tracking.xp,
currency: partial.currency ?? entry.tracking.currency,
};
await saveActorSettings(settings);
const actor = game.actors.get(actorId);
if (actor) primeActor(actor, Object.keys(partial));
}
async function setActorChat(actorId, partial) {
if (!actorId) return;
const settings = getSettingsCache();
const entry = getActorConfig(actorId);
entry.chat = {
hp: partial.hp ?? entry.chat.hp,
xp: partial.xp ?? entry.chat.xp,
currency: partial.currency ?? entry.chat.currency,
};
await saveActorSettings(settings);
}
function shouldSendChat(actorId, statId) {
const chat = getActorChat(actorId);
return !!chat[statId];
}
class TrackingLedgerConfig extends FormApplication {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "gowlers-tracking-ledger-config",
title: "Gowler's Tracking Ledger",
template: `modules/${MODULE_ID}/templates/config.hbs`,
width: 600,
height: "auto",
closeOnSubmit: true,
});
}
static PAGE_OPTIONS = [10, 20, "all"];
static DEFAULT_PAGE_SIZE = 10;
static _lastFilter = "";
static _lastPage = 0;
static _lastPageSize = 10;
constructor(...args) {
super(...args);
this._filter = TrackingLedgerConfig._lastFilter ?? "";
this._page = TrackingLedgerConfig._lastPage ?? 0;
this._pageSize = TrackingLedgerConfig._lastPageSize ?? TrackingLedgerConfig.DEFAULT_PAGE_SIZE;
this._pageMeta = { totalPages: 1, hasPrev: false, hasNext: false };
this._actorRefs = null;
this._filterDebounceTimer = null;
}
get pageSize() {
const size = this._pageSize ?? TrackingLedgerConfig.DEFAULT_PAGE_SIZE;
return size === "all" ? Infinity : (Number.isFinite(size) ? size : TrackingLedgerConfig.DEFAULT_PAGE_SIZE);
}
async getData() {
const settings = getSettingsCache();
const filter = (this._filter ?? "").trim().toLowerCase();
if (!this._actorRefs) {
this._actorRefs = game.actors.contents
.map((actor) => ({ id: actor.id, name: actor.name, nameLower: actor.name.toLowerCase() }))
.sort((a, b) => a.name.localeCompare(b.name));
}
const source = this._actorRefs;
const filtered = filter ? source.filter((ref) => ref.nameLower.includes(filter)) : source;
const totalActors = filtered.length;
const pageSize = this.pageSize;
const totalPages = Math.max(1, Math.ceil(Math.max(totalActors, 1) / pageSize));
this._page = Math.min(this._page, totalPages - 1);
const start = this._page * pageSize;
const pageItems = filtered.slice(start, start + pageSize).map((ref) => {
const entry = settings[ref.id] ?? createActorConfig();
return {
id: ref.id,
name: ref.name,
tracking: { ...entry.tracking },
chat: { ...entry.chat },
};
});
const showingFrom = totalActors ? start + 1 : 0;
const showingTo = totalActors ? start + pageItems.length : 0;
const hasPrev = this._page > 0;
const hasNext = this._page < totalPages - 1;
this._pageMeta = { totalPages, hasPrev, hasNext };
TrackingLedgerConfig._lastFilter = this._filter;
TrackingLedgerConfig._lastPage = this._page;
TrackingLedgerConfig._lastPageSize = pageSize;
const pageOptions = TrackingLedgerConfig.PAGE_OPTIONS.map((value) => ({
value,
selected: value === pageSize,
}));
const totalPagesDisplay = totalActors ? totalPages : 0;
return {
actors: pageItems,
filter: this._filter,
page: this._page,
pageSize,
pageOptions,
totalPages,
totalPagesDisplay,
totalActors,
showingFrom,
showingTo,
hasPrev,
hasNext,
displayPage: totalActors ? this._page + 1 : 0,
};
}
async _updateObject(_event, formData) {
const expanded = foundry.utils.expandObject(formData) ?? {};
const actorPayload = expanded.actors ?? {};
const rows = Object.entries(actorPayload).filter(([, cfg]) => cfg && Object.prototype.hasOwnProperty.call(cfg, "__present"));
if (!rows.length) return;
const settings = getSettingsCache();
let dirty = false;
for (const [actorId, cfg] of rows) {
const entry = getActorConfig(actorId);
const nextTracking = {
hp: checkboxValue(cfg.tracking?.hp, false),
xp: checkboxValue(cfg.tracking?.xp, false),
currency: checkboxValue(cfg.tracking?.currency, false),
};
const nextChat = {
hp: checkboxValue(cfg.chat?.hp, false),
xp: checkboxValue(cfg.chat?.xp, false),
currency: checkboxValue(cfg.chat?.currency, false),
};
if (
entry.tracking.hp !== nextTracking.hp ||
entry.tracking.xp !== nextTracking.xp ||
entry.tracking.currency !== nextTracking.currency
) {
entry.tracking = nextTracking;
dirty = true;
}
if (entry.chat.hp !== nextChat.hp || entry.chat.xp !== nextChat.xp || entry.chat.currency !== nextChat.currency) {
entry.chat = nextChat;
dirty = true;
}
}
if (dirty) {
await saveActorSettings(settings);
}
for (const [actorId] of rows) {
const actor = game.actors.get(actorId);
if (actor) primeActor(actor);
}
}
activateListeners(html) {
super.activateListeners(html);
const filterInput = html.find("[data-filter-input]");
filterInput.on("input", (event) => {
this._filter = event.currentTarget.value ?? "";
this._page = 0;
TrackingLedgerConfig._lastFilter = this._filter;
TrackingLedgerConfig._lastPage = this._page;
// Debounce render to preserve focus
clearTimeout(this._filterDebounceTimer);
this._filterDebounceTimer = setTimeout(() => {
this.render(false);
}, 300);
});
// Keep focus on filter input after render
filterInput.trigger("focus");
html.find("[data-page-size]").on("change", (event) => {
const value = event.currentTarget.value;
if (value === "all") {
this._pageSize = "all";
} else {
const num = Number(value);
this._pageSize = Number.isFinite(num) && num > 0 ? num : TrackingLedgerConfig.DEFAULT_PAGE_SIZE;
}
TrackingLedgerConfig._lastPageSize = this._pageSize;
this._page = 0;
TrackingLedgerConfig._lastPage = 0;
this.render(false);
});
html.find("[data-action=\"page\"]").on("click", (event) => {
event.preventDefault();
const direction = event.currentTarget.dataset.direction;
if (!direction) return;
const meta = this._pageMeta ?? { totalPages: 1 };
if (direction === "prev" && !meta.hasPrev) return;
if (direction === "next" && !meta.hasNext) return;
const delta = direction === "next" ? 1 : -1;
this._page = Math.min(Math.max(this._page + delta, 0), Math.max(0, meta.totalPages - 1));
TrackingLedgerConfig._lastPage = this._page;
this.render(false);
});
}
static openForActor(actorId) {
const actor = game.actors.get(actorId);
const filter = actor?.name ?? "";
TrackingLedgerConfig._lastFilter = filter;
TrackingLedgerConfig._lastPage = 0;
const app = new TrackingLedgerConfig();
app._filter = filter;
app._page = 0;
app.render(true);
}
}
/**
* Update encounter summary when combat starts
*/
async function onCombatStart(combat) {
if (!combat) return;
for (const combatant of combat.combatants) {
const actor = combatant.actor;
if (!actor) continue;
await updateEncounterSummary(actor, combat, "ongoing");
}
}
/**
* Update encounter summary when combat round changes or turns change
*/
async function onCombatUpdate(combat) {
if (!combat) return;
for (const combatant of combat.combatants) {
const actor = combatant.actor;
if (!actor) continue;
await updateEncounterSummary(actor, combat, "ongoing");
}
}
/**
* Finalize encounter summary when combat ends
*/
async function onCombatEnd(combat) {
if (!combat) return;
for (const combatant of combat.combatants) {
const actor = combatant.actor;
if (!actor) continue;
await updateEncounterSummary(actor, combat, "ended");
}
}
/**
* Update or create encounter summary for an actor
*/
async function updateEncounterSummary(actor, combat, status = "ongoing") {
if (!actor || !combat) return;
const existing = (await actor.getFlag(FLAG_SCOPE, ENCOUNTER_FLAG)) ?? [];
// Find existing entry for this combat
let encEntry = existing.find((entry) => entry.encounterID === combat.id);
if (!encEntry) {
encEntry = {
encounterID: combat.id,
dateCreated: Date.now(),
dateUpdated: Date.now(),
participants: [],
status: status,
xpGained: 0,
rounds: 0,
};
existing.unshift(encEntry);
} else {
encEntry.dateUpdated = Date.now();
encEntry.status = status;
encEntry.rounds = combat.round || 0;
}
// Update participant list with all combatants
const participantIds = new Set();
for (const combatant of combat.combatants) {
if (combatant.actor) {
participantIds.add(combatant.actor.id);
}
}
encEntry.participants = Array.from(participantIds);
// Keep recent encounters (max 50)
if (existing.length > 50) {
existing.splice(50);
}
await actor.setFlag(FLAG_SCOPE, ENCOUNTER_FLAG, existing);
}
function sendChatNotification(statId, actor, previous, nextValue, entry) {
const config = STAT_CONFIGS[statId];
if (!config) return;
const titles = {
hp: "HP",
xp: "XP",
currency: "Currency",
};
const title = titles[statId] ?? statId.toUpperCase();
const prevText = config.formatValue(previous);
const nextText = entry.value;
const content = `
<strong>${title} Log</strong><br>
${actor.name}: ${prevText} -> ${nextText} (${entry.diff})<br>
<em>User:</em> ${entry.user ?? "System"}
`;
ChatMessage.create({
content,
speaker: { alias: "Tracking Ledger" },
}).catch((err) => console.error("Tracking Ledger | Failed to post chat notification", err));
}

View File

@@ -3,7 +3,7 @@
"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",
"version": "0.1.3",
"authors": [
{ "name": "Gowler", "url": "https://foundryvtt.com" }
],

View File

@@ -1,6 +1,6 @@
const MODULE_ID = "gowlers-tracking-ledger";
const MODULE_VERSION = "0.1.0";
const MODULE_VERSION = "0.1.3";
const TRACK_SETTING = "actorSettings";
const FLAG_SCOPE = "world";
const MAX_HISTORY_ROWS = 100;
@@ -303,40 +303,33 @@ function openHistoryDialog(actor, initialTab = "hp") {
height: "auto",
classes: ["pf1-history-dialog"],
render: (html) => {
// Use jQuery for robust event delegation
// Handle both jQuery objects and DOM elements
const $html = $(html);
const $root = $html.find('[data-history-root]');
if (!$root.length) return;
// Tab switching with jQuery delegation
$root.on('click', '[data-history-tab]', function(e) {
e.preventDefault();
const tabId = $(this).data('history-tab');
// Update tab buttons
$root.find('[data-history-tab]').removeClass('active');
$root.find(`[data-history-tab="${tabId}"]`).addClass('active');
// Update panels
$root.find('[data-history-panel]').hide();
$root.find(`[data-history-panel="${tabId}"]`).show();
});
// Config button handler
$root.on('click', '[data-action="open-config"]', function(e) {
e.preventDefault();
const api = window.GowlersTrackingLedger;
if (api?.openConfigForActor) {
api.openConfigForActor(actor.id);
} else if (api?.openConfig) {
api.openConfig();
if (!$root.length) {
console.warn("[GowlersTracking] Root not found");
return;
}
// Tab switching
$root.find('[data-history-tab]').off('click').on('click', function(e) {
e.preventDefault();
const tabId = $(this).attr('data-history-tab');
console.log("[GowlersTracking] Tab clicked:", tabId);
// Remove active from all tabs and hide all panels
$root.find('[data-history-tab]').removeClass('active');
$root.find('[data-history-panel]').hide();
// Add active to clicked tab and show its panel
$root.find(`[data-history-tab="${tabId}"]`).addClass('active');
$root.find(`[data-history-panel="${tabId}"]`).show();
});
// Add config icon to dialog header (GM only)
if (game.user?.isGM) {
const $header = $html.find('.dialog-header');
const $header = $html.closest('.dialog').find('.dialog-header');
if ($header.length && !$header.find('[data-history-config-header]').length) {
const $configBtn = $('<button/>', {
type: 'button',
@@ -356,12 +349,11 @@ function openHistoryDialog(actor, initialTab = "hp") {
}
});
$configBtn.hover(
function() { $(this).css('color', '#333'); },
function() { $(this).css('color', '#999'); }
);
$configBtn.click(function(e) {
$configBtn.on('mouseenter', function() {
$(this).css('color', '#333');
}).on('mouseleave', function() {
$(this).css('color', '#999');
}).on('click', function(e) {
e.preventDefault();
const api = window.GowlersTrackingLedger;
if (api?.openConfigForActor) {
@@ -372,12 +364,9 @@ function openHistoryDialog(actor, initialTab = "hp") {
});
$header.find('.close').before($configBtn);
console.log("[GowlersTracking] Config button added");
}
}
// Set initial tab
$root.find(`[data-history-tab="${initialTab}"]`).addClass('active');
$root.find(`[data-history-panel="${initialTab}"]`).show();
},
}
);
@@ -385,7 +374,9 @@ function openHistoryDialog(actor, initialTab = "hp") {
dialog.render(true);
}
function buildHistoryContent(actor, initialTab = "hp") {
function buildHistoryContent(actor, tabArg) {
const initialTab = tabArg ?? "hp"; // Explicitly capture the tab parameter
console.log("[GowlersTracking] buildHistoryContent called with initialTab:", initialTab);
const canConfigure = game.user?.isGM;
const configs = [
{
@@ -440,8 +431,10 @@ function buildHistoryContent(actor, initialTab = "hp") {
const tabs = configs
.map(
(cfg) =>
`<a class="item history-tab-link ${cfg.id === initialTab ? "active" : ""}" data-history-tab="${cfg.id}">${cfg.label}</a>`
(cfg) => {
const isActive = cfg.id === initialTab ? "active" : "";
return `<a class="item history-tab-link ${isActive}" data-history-tab="${cfg.id}">${cfg.label}</a>`;
}
)
.join("");
@@ -449,7 +442,8 @@ function buildHistoryContent(actor, initialTab = "hp") {
.map((cfg) => {
const entries = actor.getFlag(FLAG_SCOPE, cfg.flag) ?? [];
const display = cfg.id === initialTab ? "block" : "none";
return `<div class="history-panel tab ${cfg.id === initialTab ? "active" : ""}" data-history-panel="${cfg.id}" style="display:${display}">
const isActive = cfg.id === initialTab ? "active" : "";
return `<div class="history-panel tab ${isActive}" data-history-panel="${cfg.id}" style="display:${display}">
${renderHistoryTable(entries, cfg.columns, cfg.id)}
</div>`;
})