fix(gowlers-tracking-ledger): link XP gains to encounters after combat ends
- Add lastCombatId and lastCombatEndTime tracking to preserve encounter data - Update recordHistoryEntry to link XP within 5 seconds of combat end to last encounter - Change encounter status from 'ended' to 'finished' for clarity - Store encounter ID when combat ends to catch post-combat XP awards Fixes issue where XP gained after combat ends was not linked to the encounter, and encounters were not being marked as finished in the history. Update version to 0.1.8 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
|
||||
const MODULE_ID = "gowlers-tracking-ledger";
|
||||
const MODULE_VERSION = "0.1.7";
|
||||
const MODULE_VERSION = "0.1.8";
|
||||
const TRACK_SETTING = "actorSettings";
|
||||
const FLAG_SCOPE = "world";
|
||||
const MAX_HISTORY_ROWS = 100;
|
||||
@@ -76,6 +76,8 @@ const ledgerState = {
|
||||
createHook: null,
|
||||
deleteHook: null,
|
||||
actorSettings: null,
|
||||
lastCombatId: null, // Track last combat ID to link XP gains after combat ends
|
||||
lastCombatEndTime: 0, // Timestamp when combat ended
|
||||
};
|
||||
|
||||
// Global tab switching function for history dialog
|
||||
@@ -542,13 +544,27 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId) {
|
||||
if (!config) return;
|
||||
|
||||
const diffValue = config.diff(previous, nextValue);
|
||||
|
||||
// Determine encounter ID: use active combat, or if none, check if combat just ended
|
||||
let encounterId = game.combat?.id ?? null;
|
||||
|
||||
// If no active combat but XP is being recorded shortly after combat ended, link to last encounter
|
||||
if (!encounterId && statId === "xp" && ledgerState.lastCombatId) {
|
||||
const timeSinceEnd = Date.now() - ledgerState.lastCombatEndTime;
|
||||
// Link to last encounter if within 5 seconds (allows time for XP award after combat ends)
|
||||
if (timeSinceEnd < 5000) {
|
||||
encounterId = ledgerState.lastCombatId;
|
||||
console.log("[GowlersTracking] Linking XP entry to last encounter:", encounterId);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
encounterId: encounterId,
|
||||
};
|
||||
|
||||
const existing = (await actor.getFlag(FLAG_SCOPE, config.flag)) ?? [];
|
||||
@@ -959,10 +975,16 @@ async function onCombatUpdate(combat) {
|
||||
*/
|
||||
async function onCombatEnd(combat) {
|
||||
if (!combat) return;
|
||||
|
||||
// Store combat ID to link XP gains that occur after combat ends
|
||||
ledgerState.lastCombatId = combat.id;
|
||||
ledgerState.lastCombatEndTime = Date.now();
|
||||
console.log("[GowlersTracking] Combat ended, storing encounter ID:", combat.id);
|
||||
|
||||
for (const combatant of combat.combatants) {
|
||||
const actor = combatant.actor;
|
||||
if (!actor) continue;
|
||||
await updateEncounterSummary(actor, combat, "ended");
|
||||
await updateEncounterSummary(actor, combat, "finished");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.7",
|
||||
"version": "0.1.8",
|
||||
"authors": [
|
||||
{ "name": "Gowler", "url": "https://foundryvtt.com" }
|
||||
],
|
||||
|
||||
@@ -1,995 +0,0 @@
|
||||
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user