sth
This commit is contained in:
@@ -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 SETTINGS_VERSION = 2;
|
||||
const COIN_ORDER = ["pp", "gp", "sp", "cp"];
|
||||
const ENCOUNTER_FLAG = "pf1EncounterHistory";
|
||||
|
||||
const STAT_CONFIGS = {
|
||||
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 = {
|
||||
initialized: true,
|
||||
openConfig: () => new TrackingLedgerConfig().render(true),
|
||||
@@ -284,7 +290,9 @@ function findHeaderByText(root, text) {
|
||||
function openHistoryDialog(actor, initialTab = "hp") {
|
||||
if (!actor) return;
|
||||
const content = buildHistoryContent(actor, initialTab);
|
||||
new Dialog(
|
||||
|
||||
// Create a custom dialog with header button
|
||||
const dialog = new Dialog(
|
||||
{
|
||||
title: `${actor.name}: Log`,
|
||||
content,
|
||||
@@ -293,8 +301,107 @@ function openHistoryDialog(actor, initialTab = "hp") {
|
||||
{
|
||||
width: 720,
|
||||
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") {
|
||||
@@ -309,7 +416,7 @@ function buildHistoryContent(actor, initialTab = "hp") {
|
||||
{ label: "HP", render: (entry) => entry.value },
|
||||
{ label: "Δ", render: (entry) => entry.diff },
|
||||
{ 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: "Δ", render: (entry) => entry.diff },
|
||||
{ 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: "Δ", render: (entry) => entry.diff },
|
||||
{ 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}
|
||||
<nav class="history-dialog-tabs">${tabs}</nav>
|
||||
<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>`;
|
||||
}
|
||||
|
||||
@@ -448,6 +540,7 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId) {
|
||||
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)) ?? [];
|
||||
@@ -641,11 +734,11 @@ class TrackingLedgerConfig extends FormApplication {
|
||||
});
|
||||
}
|
||||
|
||||
static PAGE_OPTIONS = [25, 50, 100, 250];
|
||||
static DEFAULT_PAGE_SIZE = 50;
|
||||
static PAGE_OPTIONS = [10, 20, "all"];
|
||||
static DEFAULT_PAGE_SIZE = 10;
|
||||
static _lastFilter = "";
|
||||
static _lastPage = 0;
|
||||
static _lastPageSize = 50;
|
||||
static _lastPageSize = 10;
|
||||
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
@@ -654,10 +747,12 @@ class TrackingLedgerConfig extends FormApplication {
|
||||
this._pageSize = TrackingLedgerConfig._lastPageSize ?? TrackingLedgerConfig.DEFAULT_PAGE_SIZE;
|
||||
this._pageMeta = { totalPages: 1, hasPrev: false, hasNext: false };
|
||||
this._actorRefs = null;
|
||||
this._filterDebounceTimer = null;
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -771,17 +866,30 @@ class TrackingLedgerConfig extends FormApplication {
|
||||
|
||||
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._page = 0;
|
||||
TrackingLedgerConfig._lastFilter = this._filter;
|
||||
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) => {
|
||||
const value = Number(event.currentTarget.value);
|
||||
this._pageSize = Number.isFinite(value) && value > 0 ? value : TrackingLedgerConfig.DEFAULT_PAGE_SIZE;
|
||||
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;
|
||||
@@ -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) {
|
||||
const config = STAT_CONFIGS[statId];
|
||||
if (!config) return;
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
<form class="tracking-ledger-config">
|
||||
<section class="tracking-ledger-controls">
|
||||
<label class="tracking-ledger-filter">
|
||||
Filter actors:
|
||||
<input type="text" data-filter-input placeholder="Type a name..." value="{{filter}}" autocomplete="off" autofocus>
|
||||
</label>
|
||||
<label class="tracking-ledger-page-size">
|
||||
Rows per page:
|
||||
<select data-page-size>
|
||||
{{#each pageOptions}}
|
||||
<option value="{{value}}" {{#if selected}}selected{{/if}}>{{value}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<div class="tracking-ledger-pagination">
|
||||
<button type="button" data-action="page" data-direction="prev" {{#unless hasPrev}}disabled{{/unless}}>« Prev</button>
|
||||
{{#if totalActors}}
|
||||
<span>Page {{displayPage}} / {{totalPages}}</span>
|
||||
{{else}}
|
||||
<span>Page 0 / 0</span>
|
||||
{{/if}}
|
||||
<button type="button" data-action="page" data-direction="next" {{#unless hasNext}}disabled{{/unless}}>Next »</button>
|
||||
<div style="display: flex; gap: 15px; align-items: center; margin-bottom: 10px; flex-wrap: nowrap;">
|
||||
<label class="tracking-ledger-filter" style="flex: 1; min-width: 200px;">
|
||||
Filter actors:
|
||||
<input type="text" data-filter-input placeholder="Type a name..." value="{{filter}}" autocomplete="off" autofocus style="width: 100%;">
|
||||
</label>
|
||||
<label class="tracking-ledger-page-size" style="white-space: nowrap;">
|
||||
Rows per page:
|
||||
<select data-page-size style="margin-left: 5px;">
|
||||
{{#each pageOptions}}
|
||||
<option value="{{value}}" {{#if selected}}selected{{/if}}>{{#if value}}{{value}}{{else}}All{{/if}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<div class="tracking-ledger-pagination" style="display: flex; gap: 4px; align-items: center; white-space: nowrap; flex-shrink: 0;">
|
||||
<button type="button" data-action="page" data-direction="prev" {{#unless hasPrev}}disabled{{/unless}} style="padding: 2px 6px;">« Prev</button>
|
||||
{{#if totalActors}}
|
||||
<span style="font-size: 0.9em; min-width: 80px; text-align: center;">Page {{displayPage}} / {{totalPages}}</span>
|
||||
{{else}}
|
||||
<span style="font-size: 0.9em; min-width: 80px; text-align: center;">Page 0 / 0</span>
|
||||
{{/if}}
|
||||
<button type="button" data-action="page" data-direction="next" {{#unless hasNext}}disabled{{/unless}} style="padding: 2px 6px;">Next »</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tracking-ledger-summary">
|
||||
<div class="tracking-ledger-summary" style="font-size: 0.9em; color: #666;">
|
||||
{{#if totalActors}}
|
||||
Showing {{showingFrom}}-{{showingTo}} of {{totalActors}} actors
|
||||
{{else}}
|
||||
|
||||
Reference in New Issue
Block a user