This commit is contained in:
centron\schwoerer
2025-11-18 16:17:05 +01:00
parent 4f0b3af6e1
commit 167cd0e02d
2 changed files with 252 additions and 61 deletions

View File

@@ -12,6 +12,7 @@ const DEFAULT_TRACKING = Object.freeze({ hp: true, xp: true, currency: true });
const DEFAULT_CHAT = Object.freeze({ hp: false, xp: false, currency: false }); const DEFAULT_CHAT = Object.freeze({ hp: false, xp: false, currency: false });
const SETTINGS_VERSION = 2; const SETTINGS_VERSION = 2;
const COIN_ORDER = ["pp", "gp", "sp", "cp"]; const COIN_ORDER = ["pp", "gp", "sp", "cp"];
const ENCOUNTER_FLAG = "pf1EncounterHistory";
const STAT_CONFIGS = { const STAT_CONFIGS = {
hp: { hp: {
@@ -103,6 +104,11 @@ async function initializeModule() {
}) })
); );
// Track combat encounters
Hooks.on("combatStart", (combat) => onCombatStart(combat));
Hooks.on("combatEnd", (combat) => onCombatEnd(combat));
Hooks.on("updateCombat", (combat) => onCombatUpdate(combat));
const api = { const api = {
initialized: true, initialized: true,
openConfig: () => new TrackingLedgerConfig().render(true), openConfig: () => new TrackingLedgerConfig().render(true),
@@ -284,7 +290,9 @@ function findHeaderByText(root, text) {
function openHistoryDialog(actor, initialTab = "hp") { function openHistoryDialog(actor, initialTab = "hp") {
if (!actor) return; if (!actor) return;
const content = buildHistoryContent(actor, initialTab); const content = buildHistoryContent(actor, initialTab);
new Dialog(
// Create a custom dialog with header button
const dialog = new Dialog(
{ {
title: `${actor.name}: Log`, title: `${actor.name}: Log`,
content, content,
@@ -293,8 +301,107 @@ function openHistoryDialog(actor, initialTab = "hp") {
{ {
width: 720, width: 720,
classes: ["pf1-history-dialog"], classes: ["pf1-history-dialog"],
render: (html) => {
// html is jQuery object of the dialog element
// Find the content root within the dialog
const root = html.find('[data-history-root]')[0];
if (!root) {
console.warn("[History Dialog] Root element not found");
return;
}
console.log("[History Dialog] Root element found, setting up tabs");
// Get all tab buttons and panels
const buttons = Array.from(root.querySelectorAll('.history-tab-link'));
const panels = Array.from(root.querySelectorAll('.history-panel'));
console.log(`[History Dialog] Found ${buttons.length} tabs and ${panels.length} panels`);
// Tab activation function
const activateTab = (tabId) => {
console.log(`[History Dialog] Activating tab: ${tabId}`);
buttons.forEach((btn) => {
const shouldBeActive = btn.dataset.historyTab === tabId;
btn.classList.toggle('active', shouldBeActive);
});
panels.forEach((panel) => {
const shouldBeActive = panel.dataset.historyPanel === tabId;
panel.style.display = shouldBeActive ? 'block' : 'none';
panel.classList.toggle('active', shouldBeActive);
});
};
// Bind tab click handlers using event delegation
buttons.forEach((btn, index) => {
btn.style.cursor = 'pointer';
btn.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation();
const tabId = this.dataset.historyTab;
console.log(`[History Dialog] Tab clicked: ${tabId}`);
activateTab(tabId);
return false;
});
});
// Bind config button in content
const configBtn = root.querySelector('[data-action="open-config"]');
if (configBtn) {
configBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
console.log("[History Dialog] Config button clicked (content)");
const api = window.GowlersTrackingLedger;
if (api?.openConfigForActor) {
api.openConfigForActor(actor.id);
} else if (api?.openConfig) {
api.openConfig();
}
});
}
// Add config button to dialog header if user is GM
if (game.user?.isGM) {
const header = html.find('.dialog-header')[0];
if (header) {
const existingBtn = header.querySelector('[data-history-config-header]');
if (!existingBtn) {
const configHeaderBtn = document.createElement('button');
configHeaderBtn.type = 'button';
configHeaderBtn.className = 'history-config-header-btn';
configHeaderBtn.setAttribute('data-history-config-header', 'true');
configHeaderBtn.setAttribute('title', 'Configure Actor Tracking');
configHeaderBtn.innerHTML = '<i class="fas fa-cog" style="font-size: 18px;"></i>';
configHeaderBtn.style.cssText = 'border: none; background: transparent; color: #666; cursor: pointer; padding: 8px 10px; margin-right: 8px; transition: color 0.2s;';
configHeaderBtn.addEventListener('mouseover', () => configHeaderBtn.style.color = '#333');
configHeaderBtn.addEventListener('mouseout', () => configHeaderBtn.style.color = '#666');
configHeaderBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
console.log("[History Dialog] Config button clicked (header)");
const api = window.GowlersTrackingLedger;
if (api?.openConfigForActor) {
api.openConfigForActor(actor.id);
} else if (api?.openConfig) {
api.openConfig();
}
});
const closeBtn = header.querySelector('.close');
if (closeBtn) {
header.insertBefore(configHeaderBtn, closeBtn);
} else {
header.appendChild(configHeaderBtn);
}
console.log("[History Dialog] Config header button added");
}
}
}
},
} }
).render(true); );
dialog.render(true);
} }
function buildHistoryContent(actor, initialTab = "hp") { function buildHistoryContent(actor, initialTab = "hp") {
@@ -309,7 +416,7 @@ function buildHistoryContent(actor, initialTab = "hp") {
{ label: "HP", render: (entry) => entry.value }, { label: "HP", render: (entry) => entry.value },
{ label: "Δ", render: (entry) => entry.diff }, { label: "Δ", render: (entry) => entry.diff },
{ label: "User", render: (entry) => entry.user ?? "" }, { label: "User", render: (entry) => entry.user ?? "" },
{ label: "Source", render: (entry) => entry.source ?? "" }, { label: "Encounter", render: (entry) => entry.encounterId ? entry.encounterId.slice(0, 8) : "N/A" },
], ],
}, },
{ {
@@ -321,7 +428,7 @@ function buildHistoryContent(actor, initialTab = "hp") {
{ label: "XP", render: (entry) => entry.value }, { label: "XP", render: (entry) => entry.value },
{ label: "Δ", render: (entry) => entry.diff }, { label: "Δ", render: (entry) => entry.diff },
{ label: "User", render: (entry) => entry.user ?? "" }, { label: "User", render: (entry) => entry.user ?? "" },
{ label: "Source", render: (entry) => entry.source ?? "" }, { label: "Encounter", render: (entry) => entry.encounterId ? entry.encounterId.slice(0, 8) : "N/A" },
], ],
}, },
{ {
@@ -333,7 +440,19 @@ function buildHistoryContent(actor, initialTab = "hp") {
{ label: "Totals", render: (entry) => entry.value }, { label: "Totals", render: (entry) => entry.value },
{ label: "Δ", render: (entry) => entry.diff }, { label: "Δ", render: (entry) => entry.diff },
{ label: "User", render: (entry) => entry.user ?? "" }, { label: "User", render: (entry) => entry.user ?? "" },
{ label: "Source", render: (entry) => entry.source ?? "" }, { 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 },
], ],
}, },
]; ];
@@ -382,33 +501,6 @@ function buildHistoryContent(actor, initialTab = "hp") {
${toolbar} ${toolbar}
<nav class="history-dialog-tabs">${tabs}</nav> <nav class="history-dialog-tabs">${tabs}</nav>
<div class="history-dialog-panels">${panels}</div> <div class="history-dialog-panels">${panels}</div>
<script type="text/javascript">
(function(){
const root = document.currentScript.closest('[data-history-root="${actor.id}"]');
if (!root) return;
const buttons = Array.from(root.querySelectorAll('.history-tab-link'));
const panels = Array.from(root.querySelectorAll('.history-panel'));
function activate(target) {
buttons.forEach((btn) => btn.classList.toggle('active', btn.dataset.historyTab === target));
panels.forEach((panel) => (panel.style.display = panel.dataset.historyPanel === target ? 'block' : 'none'));
panels.forEach((panel) => panel.classList.toggle('active', panel.dataset.historyPanel === target));
}
buttons.forEach((btn) => {
btn.addEventListener('click', (event) => {
event.preventDefault();
activate(btn.dataset.historyTab);
});
});
const configBtn = root.querySelector('[data-action="open-config"]');
configBtn?.addEventListener('click', (event) => {
event.preventDefault();
const api = window.GowlersTrackingLedger;
if (api?.openConfigForActor) api.openConfigForActor("${actor.id}");
else api?.openConfig?.();
});
activate("${initialTab}");
})();
</script>
</section>`; </section>`;
} }
@@ -448,6 +540,7 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId) {
diff: config.formatDiff(diffValue), diff: config.formatDiff(diffValue),
user: game.users.get(userId)?.name ?? "System", user: game.users.get(userId)?.name ?? "System",
source: "", source: "",
encounterId: game.combat?.id ?? null, // Track which encounter this change occurred in
}; };
const existing = (await actor.getFlag(FLAG_SCOPE, config.flag)) ?? []; const existing = (await actor.getFlag(FLAG_SCOPE, config.flag)) ?? [];
@@ -641,11 +734,11 @@ class TrackingLedgerConfig extends FormApplication {
}); });
} }
static PAGE_OPTIONS = [25, 50, 100, 250]; static PAGE_OPTIONS = [10, 20, "all"];
static DEFAULT_PAGE_SIZE = 50; static DEFAULT_PAGE_SIZE = 10;
static _lastFilter = ""; static _lastFilter = "";
static _lastPage = 0; static _lastPage = 0;
static _lastPageSize = 50; static _lastPageSize = 10;
constructor(...args) { constructor(...args) {
super(...args); super(...args);
@@ -654,10 +747,12 @@ class TrackingLedgerConfig extends FormApplication {
this._pageSize = TrackingLedgerConfig._lastPageSize ?? TrackingLedgerConfig.DEFAULT_PAGE_SIZE; this._pageSize = TrackingLedgerConfig._lastPageSize ?? TrackingLedgerConfig.DEFAULT_PAGE_SIZE;
this._pageMeta = { totalPages: 1, hasPrev: false, hasNext: false }; this._pageMeta = { totalPages: 1, hasPrev: false, hasNext: false };
this._actorRefs = null; this._actorRefs = null;
this._filterDebounceTimer = null;
} }
get pageSize() { get pageSize() {
return this._pageSize ?? TrackingLedgerConfig.DEFAULT_PAGE_SIZE; const size = this._pageSize ?? TrackingLedgerConfig.DEFAULT_PAGE_SIZE;
return size === "all" ? Infinity : (Number.isFinite(size) ? size : TrackingLedgerConfig.DEFAULT_PAGE_SIZE);
} }
async getData() { async getData() {
@@ -771,17 +866,30 @@ class TrackingLedgerConfig extends FormApplication {
activateListeners(html) { activateListeners(html) {
super.activateListeners(html); super.activateListeners(html);
html.find("[data-filter-input]").on("input", (event) => { const filterInput = html.find("[data-filter-input]");
filterInput.on("input", (event) => {
this._filter = event.currentTarget.value ?? ""; this._filter = event.currentTarget.value ?? "";
this._page = 0; this._page = 0;
TrackingLedgerConfig._lastFilter = this._filter; TrackingLedgerConfig._lastFilter = this._filter;
TrackingLedgerConfig._lastPage = this._page; TrackingLedgerConfig._lastPage = this._page;
this.render(false);
// 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) => { html.find("[data-page-size]").on("change", (event) => {
const value = Number(event.currentTarget.value); const value = event.currentTarget.value;
this._pageSize = Number.isFinite(value) && value > 0 ? value : TrackingLedgerConfig.DEFAULT_PAGE_SIZE; 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; TrackingLedgerConfig._lastPageSize = this._pageSize;
this._page = 0; this._page = 0;
TrackingLedgerConfig._lastPage = 0; TrackingLedgerConfig._lastPage = 0;
@@ -814,6 +922,87 @@ class TrackingLedgerConfig extends FormApplication {
} }
} }
/**
* 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) { function sendChatNotification(statId, actor, previous, nextValue, entry) {
const config = STAT_CONFIGS[statId]; const config = STAT_CONFIGS[statId];
if (!config) return; if (!config) return;

View File

@@ -1,27 +1,29 @@
<form class="tracking-ledger-config"> <form class="tracking-ledger-config">
<section class="tracking-ledger-controls"> <section class="tracking-ledger-controls">
<label class="tracking-ledger-filter"> <div style="display: flex; gap: 15px; align-items: center; margin-bottom: 10px; flex-wrap: nowrap;">
Filter actors: <label class="tracking-ledger-filter" style="flex: 1; min-width: 200px;">
<input type="text" data-filter-input placeholder="Type a name..." value="{{filter}}" autocomplete="off" autofocus> Filter actors:
</label> <input type="text" data-filter-input placeholder="Type a name..." value="{{filter}}" autocomplete="off" autofocus style="width: 100%;">
<label class="tracking-ledger-page-size"> </label>
Rows per page: <label class="tracking-ledger-page-size" style="white-space: nowrap;">
<select data-page-size> Rows per page:
{{#each pageOptions}} <select data-page-size style="margin-left: 5px;">
<option value="{{value}}" {{#if selected}}selected{{/if}}>{{value}}</option> {{#each pageOptions}}
{{/each}} <option value="{{value}}" {{#if selected}}selected{{/if}}>{{#if value}}{{value}}{{else}}All{{/if}}</option>
</select> {{/each}}
</label> </select>
<div class="tracking-ledger-pagination"> </label>
<button type="button" data-action="page" data-direction="prev" {{#unless hasPrev}}disabled{{/unless}}>&laquo; Prev</button> <div class="tracking-ledger-pagination" style="display: flex; gap: 4px; align-items: center; white-space: nowrap; flex-shrink: 0;">
{{#if totalActors}} <button type="button" data-action="page" data-direction="prev" {{#unless hasPrev}}disabled{{/unless}} style="padding: 2px 6px;">&laquo; Prev</button>
<span>Page {{displayPage}} / {{totalPages}}</span> {{#if totalActors}}
{{else}} <span style="font-size: 0.9em; min-width: 80px; text-align: center;">Page {{displayPage}} / {{totalPages}}</span>
<span>Page 0 / 0</span> {{else}}
{{/if}} <span style="font-size: 0.9em; min-width: 80px; text-align: center;">Page 0 / 0</span>
<button type="button" data-action="page" data-direction="next" {{#unless hasNext}}disabled{{/unless}}>Next &raquo;</button> {{/if}}
<button type="button" data-action="page" data-direction="next" {{#unless hasNext}}disabled{{/unless}} style="padding: 2px 6px;">Next &raquo;</button>
</div>
</div> </div>
<div class="tracking-ledger-summary"> <div class="tracking-ledger-summary" style="font-size: 0.9em; color: #666;">
{{#if totalActors}} {{#if totalActors}}
Showing {{showingFrom}}-{{showingTo}} of {{totalActors}} actors Showing {{showingFrom}}-{{showingTo}} of {{totalActors}} actors
{{else}} {{else}}