test
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -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" }
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
if (!$root.length) {
|
||||
console.warn("[GowlersTracking] Root not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab switching with jQuery delegation
|
||||
$root.on('click', '[data-history-tab]', function(e) {
|
||||
// Tab switching
|
||||
$root.find('[data-history-tab]').off('click').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
const tabId = $(this).data('history-tab');
|
||||
const tabId = $(this).attr('data-history-tab');
|
||||
console.log("[GowlersTracking] Tab clicked:", tabId);
|
||||
|
||||
// Update tab buttons
|
||||
// Remove active from all tabs and hide all panels
|
||||
$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();
|
||||
}
|
||||
// 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>`;
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user