Bump version and finalize history clearing

This commit is contained in:
centron\schwoerer
2025-11-24 12:37:29 +01:00
parent 2c1e9d0fef
commit ec3e65c0f0
2 changed files with 380 additions and 74 deletions

View File

@@ -1,6 +1,6 @@
const MODULE_ID = "gowlers-tracking-ledger";
const MODULE_VERSION = "1.1.1";
const MODULE_VERSION = "1.2.1";
const TRACK_SETTING = "actorSettings";
const FLAG_SCOPE = "world";
const MAX_HISTORY_ROWS = 100;
@@ -9,8 +9,8 @@ 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 DEFAULT_TRACKING = Object.freeze({ hp: true, outgoing: true, xp: true, currency: true, encounters: true });
const DEFAULT_CHAT = Object.freeze({ hp: false, outgoing: false, xp: false, currency: false, encounters: false, chatAll: false });
const SETTINGS_VERSION = 2;
const COIN_ORDER = ["pp", "gp", "sp", "cp"];
const ENCOUNTER_FLAG = "pf1EncounterHistory";
@@ -85,6 +85,7 @@ const ledgerState = {
historyTabState: new Map(), // actorId -> active tab id
damageOverlay: null,
damageMeterIncludeNPCs: true,
damageMeterMode: "encounter", // "encounter" or "all"
};
function getHistoryPageState(actorId, tabId) {
@@ -275,6 +276,10 @@ Hooks.once("ready", async () => {
ledgerState.damageMeterIncludeNPCs = !!checked;
refreshDamageMeterOverlay();
};
window.GowlersTrackingDamageMeterSetMode = (mode) => {
ledgerState.damageMeterMode = mode === "all" ? "all" : "encounter";
refreshDamageMeterOverlay();
};
});
async function initializeModule() {
@@ -587,6 +592,20 @@ function registerSceneControls() {
button: true,
onClick: () => openDamageMeterOverlay(),
});
tokenControls.tools.push({
name: "history-ledger",
title: "History Ledger",
icon: "fas fa-scroll",
button: true,
onClick: () => {
const actor = canvas?.tokens?.controlled?.[0]?.actor ?? null;
if (actor) {
openHistoryDialog(actor, "hp");
} else {
ui.notifications?.warn?.("Select a token to open its history ledger.");
}
},
});
});
}
@@ -611,7 +630,7 @@ function openDamageMeterOverlay() {
},
}, {
id: `${MODULE_ID}-damage-meter`,
width: 300,
width: 420,
height: "auto",
resizable: true,
});
@@ -967,9 +986,9 @@ function buildHistoryContent(actor, tabArg) {
setActiveHistoryTab(actor.id, initialTab);
const canConfigure = game.user?.isGM;
const configs = [
{
id: "hp",
label: "HP",
{
id: "hp",
label: "Incoming",
flag: STAT_CONFIGS.hp.flag,
columns: [
{ label: "Timestamp", render: (entry) => formatDate(entry.timestamp) },
@@ -985,11 +1004,12 @@ function buildHistoryContent(actor, tabArg) {
},
{
id: "damage",
label: "Damage",
label: "Outgoing",
flag: DAMAGE_DEALT_FLAG,
columns: [
{ label: "Timestamp", render: (entry) => formatDate(entry.timestamp) },
{ label: "Dmg", render: (entry) => entry.amount != null ? `${entry.amount}` : "" },
{ label: "Dmg", render: (entry) => entry.type === "healing" ? "" : (entry.amount != null ? `${entry.amount}` : "") },
{ label: "Heal", render: (entry) => entry.type === "healing" ? `${entry.amount}` : "" },
{
label: "Details",
render: (entry) => {
@@ -1291,6 +1311,7 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op
source: source,
breakdown: damageBreakdown,
encounterId: encounterId,
type: "damage",
damageDetails: matchedMessage.damageDetails ?? null,
};
recordDamageDealt(attacker, dealtEntry);
@@ -1300,6 +1321,33 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op
}
}
}
// For HP healing, also try to match recent messages (same mechanism as damage)
else if (statId === "hp" && diffValue > 0) {
const healAmount = Math.abs(diffValue);
const now = Date.now();
for (let i = ledgerState.recentMessages.length - 1; i >= 0; i--) {
const msg = ledgerState.recentMessages[i];
const timeSinceMessage = now - msg.timestamp;
const valueMatch = Math.abs(Math.abs(msg.value) - healAmount) <= Math.max(1, healAmount * 0.1);
if (timeSinceMessage < 4000 && valueMatch) {
matchedMessage = msg;
console.log("[GowlersTracking] Found matching healing message:", msg.source, "value:", msg.value, "vs", healAmount);
ledgerState.recentMessages.splice(i, 1);
break;
}
}
if (matchedMessage) {
source = matchedMessage.source;
damageBreakdown =
formatDamageBreakdown(matchedMessage.damageDetails, healAmount) ||
matchedMessage.damageDetails?.breakdown ||
`${healAmount} healing`;
console.log(`[GowlersTracking] Using matched healing source:`, source);
}
}
// Fallback for unmatched HP changes
if (source === "Manual" && statId === "hp") {
@@ -1312,6 +1360,31 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op
source = "Healing";
damageBreakdown = `${hpDiff} healing`;
}
} else if (statId === "hp" && diffValue > 0) {
// Record outgoing healing (simple)
const metadata = change?.metadata ?? options?.message?.flags?.pf1?.metadata ?? {};
const healer = resolveActorFromMetadataSafe(metadata) || resolveActorFromMetadataSafe(matchedMessage?.message?.flags?.pf1?.metadata) || actor; // fallback to self
if (matchedMessage) {
source = matchedMessage.source;
damageBreakdown =
formatDamageBreakdown(matchedMessage.damageDetails, diffValue) ||
matchedMessage.damageDetails?.breakdown ||
`${diffValue} healing`;
} else {
source = healer?.name ? `${healer.name} -> Healing` : "Healing";
damageBreakdown = `${diffValue} healing`;
}
const healEntry = {
timestamp: Date.now(),
amount: diffValue,
target: actor.name,
source: source,
breakdown: damageBreakdown,
encounterId: encounterId,
type: "healing",
damageDetails: matchedMessage?.damageDetails ?? null,
};
recordDamageDealt(healer, healEntry);
} else if (statId === "xp" && diffValue > 0) {
// XP gains - encounter or manual award
source = options?.source ?? (encounterId ? "Encounter XP Award" : "XP Award");
@@ -1525,6 +1598,7 @@ function formatDamagePartsWithIcons(parts) {
force: { icon: "ra ra-crystal-ball", color: "#845ef7" },
negative: { icon: "ra ra-skull", color: "#7950f2" },
positive: { icon: "ra ra-sun", color: "#fab005" },
healing: { icon: "ra ra-health", color: "#4caf50" },
precision: { icon: "ra ra-target-arrows", color: "#000" },
nonlethal: { icon: "ra ra-hand", color: "#000" },
untyped: { icon: "ra ra-uncertainty", color: "#666" },
@@ -1553,6 +1627,7 @@ function renderDamageBar(composition = [], total = 0) {
force: { icon: "ra ra-crystal-ball", color: "#845ef7" },
negative: { icon: "ra ra-skull", color: "#7950f2" },
positive: { icon: "ra ra-sun", color: "#fab005" },
healing: { icon: "ra ra-health", color: "#4caf50" },
precision: { icon: "ra ra-target-arrows", color: "#000" },
nonlethal: { icon: "ra ra-hand", color: "#000" },
untyped: { icon: "ra ra-uncertainty", color: "#666" },
@@ -1684,7 +1759,8 @@ function refreshDamageMeterOverlay() {
}
function computeDamageMeterData() {
const currentEncounterId = game.combat?.id ?? ledgerState.lastCombatId ?? null;
const mode = ledgerState.damageMeterMode === "all" ? "all" : "encounter";
const currentEncounterId = mode === "encounter" ? (game.combat?.id ?? ledgerState.lastCombatId ?? null) : null;
const actorsMap = new Map();
// Directory actors
@@ -1724,10 +1800,13 @@ function computeDamageMeterData() {
if (!filtered.length) continue;
const typeTotals = new Map();
let total = 0;
let totalDamage = 0;
let totalHealing = 0;
for (const e of filtered) {
const amount = Number(e.amount) || 0;
total += amount;
if (e.type === "healing") totalHealing += amount;
else totalDamage += amount;
const parts = e.damageDetails?.parts;
if (Array.isArray(parts) && parts.length) {
for (const p of parts) {
@@ -1736,8 +1815,9 @@ function computeDamageMeterData() {
typeTotals.set(t, prev + (Number(p.total) || 0));
}
} else {
const prev = typeTotals.get("untyped") ?? 0;
typeTotals.set("untyped", prev + amount);
const key = e.type === "healing" ? "healing" : "untyped";
const prev = typeTotals.get(key) ?? 0;
typeTotals.set(key, prev + amount);
}
}
@@ -1749,7 +1829,9 @@ function computeDamageMeterData() {
totals.push({
actorId: actor.id,
name: actor.name,
total,
total: totalDamage,
healing: totalHealing,
overall: totalDamage + totalHealing,
hits: filtered.length,
last: filtered[0],
img,
@@ -1757,29 +1839,44 @@ function computeDamageMeterData() {
});
}
totals.sort((a, b) => b.total - a.total);
totals.sort((a, b) => (b.overall ?? b.total) - (a.overall ?? a.total));
const grandTotal = totals.reduce((s, t) => s + t.total, 0);
return { totals, grandTotal, encounterId: currentEncounterId };
const grandHealing = totals.reduce((s, t) => s + (t.healing ?? 0), 0);
return { totals, grandTotal, grandHealing, encounterId: currentEncounterId };
}
function buildDamageMeterContent() {
const { totals, grandTotal, encounterId } = computeDamageMeterData();
const { totals, grandTotal, grandHealing, encounterId } = computeDamageMeterData();
const encounterLabel = encounterId ? `Encounter: ${encounterId.slice(0, 8)}` : "All history";
const includeNPCs = ledgerState.damageMeterIncludeNPCs;
const npcToggle = `<label style="font-size:12px; display:inline-flex; align-items:center; gap:4px; margin:0;">
<input type="checkbox" ${includeNPCs ? "checked" : ""} onchange="window.GowlersTrackingDamageMeterToggleNPCs?.(this.checked)">
Include NPCs
</label>`;
const modeToggle = `
<div style="display:inline-flex; border:1px solid #b5b3a4; border-radius:12px; overflow:hidden; height:24px;">
<button type="button" onclick="window.GowlersTrackingDamageMeterSetMode('encounter')" style="border:none; background:${ledgerState.damageMeterMode === "encounter" ? "#d6d3c8" : "#f5f4ef"}; padding:2px 8px; cursor:pointer; font-size:11px;">Encounter</button>
<button type="button" onclick="window.GowlersTrackingDamageMeterSetMode('all')" style="border:none; border-left:1px solid #b5b3a4; background:${ledgerState.damageMeterMode === "all" ? "#d6d3c8" : "#f5f4ef"}; padding:2px 8px; cursor:pointer; font-size:11px;">All-Time</button>
</div>`;
const rows = totals.length
? totals
.map(
(t, idx) => {
const max = totals[0]?.total || 1;
const pct = Math.max(1, Math.round((t.total / max) * 100));
const bar = renderDamageBar(t.composition, t.total) || `<div style="background:#e0e0e0; border-radius:4px; overflow:hidden; height:10px; margin-bottom:4px;"><div style="width:${pct}%; background:#ff9800; height:10px;"></div></div>`;
const max = totals[0]?.overall || totals[0]?.total || 1;
const pct = Math.max(1, Math.round(((t.overall ?? t.total) / max) * 100));
const innerBar =
renderDamageBar(t.composition, t.overall ?? t.total) ||
`<div style="background:#e0e0e0; border-radius:4px; overflow:hidden; height:10px;"><div style="width:${pct}%; background:#ff9800; height:10px;"></div></div>`;
const bar = `<div style="width:${pct}%; min-width:4px; max-width:100%;">${innerBar}</div>`;
const avatar = t.img ? `<img src="${t.img}" style="width:18px; height:18px; object-fit:cover; border-radius:3px; margin-right:6px;" />` : "";
return `
<tr>
<td style="padding:4px 6px;">${idx + 1}</td>
<td style="padding:4px 6px; display:flex; align-items:center;">${avatar}${t.name}</td>
<td style="padding:4px 6px; text-align:right;">${t.total}</td>
<td style="padding:4px 6px; text-align:right;">${t.healing}</td>
<td style="padding:4px 6px; text-align:right;">${t.hits}</td>
<td style="padding:4px 6px; max-width: 220px;">
${bar}
@@ -1789,26 +1886,23 @@ function buildDamageMeterContent() {
}
)
.join("")
: `<tr><td colspan="5" style="padding:6px; text-align:center; opacity:0.7;">No damage recorded${encounterId ? " for this encounter" : ""}.</td></tr>`;
const npcToggle = `<label style="font-size:12px; display:inline-flex; align-items:center; gap:4px; margin-left:6px;">
<input type="checkbox" ${includeNPCs ? "checked" : ""} onchange="window.GowlersTrackingDamageMeterToggleNPCs?.(this.checked)">
Include NPCs
</label>`;
: `<tr><td colspan="6" style="padding:6px; text-align:center; opacity:0.7;">No damage recorded${encounterId ? " for this encounter" : ""}.</td></tr>`;
return `
<div style="padding:8px; font-size: 13px; line-height: 1.4;">
<div style="font-weight:bold; margin-bottom:4px;">Damage Meter</div>
<div style="margin-bottom:6px; display:flex; align-items:center; gap:12px;">
<span>${encounterLabel}</span>
${npcToggle}
<div style="margin-bottom:6px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<div style="display:flex; align-items:center; gap:6px;">${modeToggle}</div>
<div style="display:flex; align-items:center; gap:6px;">${npcToggle}</div>
<div style="display:flex; align-items:center; gap:6px;">${encounterLabel}</div>
</div>
<table style="width:100%; border-collapse:collapse; font-size:12px;">
<thead>
<tr>
<th style="text-align:left; padding:4px 6px;">#</th>
<th style="text-align:left; padding:4px 6px;">Actor</th>
<th style="text-align:right; padding:4px 6px;">Total</th>
<th style="text-align:right; padding:4px 6px;">Damage</th>
<th style="text-align:right; padding:4px 6px;">Healing</th>
<th style="text-align:right; padding:4px 6px;">Hits</th>
<th style="text-align:left; padding:4px 6px;">Last Breakdown</th>
</tr>
@@ -1821,6 +1915,7 @@ function buildDamageMeterContent() {
<td></td>
<td style="padding:4px 6px; font-weight:bold;">Total</td>
<td style="padding:4px 6px; text-align:right; font-weight:bold;">${grandTotal}</td>
<td style="padding:4px 6px; text-align:right; font-weight:bold;">${grandHealing}</td>
<td></td>
<td></td>
</tr>
@@ -1853,6 +1948,7 @@ function createActorConfig(source = null) {
if (source.version === SETTINGS_VERSION) {
config.tracking = { ...config.tracking, ...(source.tracking ?? {}) };
config.chat = { ...config.chat, ...(source.chat ?? {}) };
config.chat.chatAll = source.chat?.chatAll ?? Object.values(source.chat ?? {}).some(Boolean);
return config;
}
@@ -1912,7 +2008,8 @@ function getActorTracking(actorId) {
function getActorChat(actorId) {
const entry = getActorConfig(actorId);
return { ...entry.chat };
const hasAny = Object.values(entry.chat ?? {}).some(Boolean);
return { ...entry.chat, chatAll: entry.chat?.chatAll ?? hasAny };
}
async function ensureActorConfig(actor) {
@@ -1931,8 +2028,9 @@ async function setActorTracking(actorId, partial) {
const entry = getActorConfig(actorId);
entry.tracking = {
hp: partial.hp ?? entry.tracking.hp,
xp: partial.xp ?? entry.tracking.xp,
outgoing: partial.outgoing ?? entry.tracking.outgoing,
currency: partial.currency ?? entry.tracking.currency,
encounters: partial.encounters ?? entry.tracking.encounters,
};
await saveActorSettings(settings);
const actor = game.actors.get(actorId);
@@ -1943,17 +2041,32 @@ async function setActorChat(actorId, partial) {
if (!actorId) return;
const settings = getSettingsCache();
const entry = getActorConfig(actorId);
const chatAll = partial.chatAll;
const mergedChat = { ...entry.chat, ...partial };
if (chatAll !== undefined && chatAll !== null) {
mergedChat.hp = !!chatAll;
mergedChat.outgoing = !!chatAll;
mergedChat.xp = !!chatAll;
mergedChat.currency = !!chatAll;
mergedChat.encounters = !!chatAll;
mergedChat.chatAll = !!chatAll;
} else {
mergedChat.chatAll = !!mergedChat.chatAll;
}
entry.chat = {
hp: partial.hp ?? entry.chat.hp,
xp: partial.xp ?? entry.chat.xp,
currency: partial.currency ?? entry.chat.currency,
hp: mergedChat.hp,
outgoing: mergedChat.outgoing,
xp: mergedChat.xp,
currency: mergedChat.currency,
encounters: mergedChat.encounters,
chatAll: mergedChat.chatAll,
};
await saveActorSettings(settings);
}
function shouldSendChat(actorId, statId) {
const chat = getActorChat(actorId);
return !!chat[statId];
return chat.chatAll ?? !!chat[statId];
}
class TrackingLedgerConfig extends FormApplication {
@@ -2009,11 +2122,13 @@ class TrackingLedgerConfig extends FormApplication {
const start = this._page * pageSize;
const pageItems = filtered.slice(start, start + pageSize).map((ref) => {
const entry = settings[ref.id] ?? createActorConfig();
const chatAll = entry.chat?.chatAll ?? false;
return {
id: ref.id,
name: ref.name,
tracking: { ...entry.tracking },
chat: { ...entry.chat },
chat: { ...entry.chat, chatAll },
chatAll: chatAll,
};
});
@@ -2033,6 +2148,12 @@ class TrackingLedgerConfig extends FormApplication {
}));
const totalPagesDisplay = totalActors ? totalPages : 0;
const allEntries = Object.values(settings);
const allIncoming = allEntries.length ? allEntries.every((e) => e.tracking?.hp) : false;
const allOutgoing = allEntries.length ? allEntries.every((e) => e.tracking?.outgoing) : false;
const allCurrency = allEntries.length ? allEntries.every((e) => e.tracking?.currency) : false;
const allEncounters = allEntries.length ? allEntries.every((e) => e.tracking?.encounters) : false;
const allChatAll = allEntries.length ? allEntries.every((e) => e.chat?.chatAll ?? false) : false;
return {
actors: pageItems,
@@ -2048,6 +2169,12 @@ class TrackingLedgerConfig extends FormApplication {
hasPrev,
hasNext,
displayPage: totalActors ? this._page + 1 : 0,
moduleVersion: MODULE_VERSION,
allIncoming,
allOutgoing,
allCurrency,
allEncounters,
allChatAll,
};
}
@@ -2065,25 +2192,40 @@ class TrackingLedgerConfig extends FormApplication {
const entry = getActorConfig(actorId);
const nextTracking = {
hp: checkboxValue(cfg.tracking?.hp, false),
xp: checkboxValue(cfg.tracking?.xp, false),
outgoing: checkboxValue(cfg.tracking?.outgoing, false),
xp: entry.tracking?.xp ?? true,
currency: checkboxValue(cfg.tracking?.currency, false),
encounters: checkboxValue(cfg.tracking?.encounters, false),
};
const chatAll = checkboxValue(cfg.chatAll, false);
const nextChat = {
hp: checkboxValue(cfg.chat?.hp, false),
xp: checkboxValue(cfg.chat?.xp, false),
currency: checkboxValue(cfg.chat?.currency, false),
hp: chatAll,
outgoing: chatAll,
xp: chatAll,
currency: chatAll,
encounters: chatAll,
chatAll: chatAll,
};
if (
entry.tracking.hp !== nextTracking.hp ||
entry.tracking.outgoing !== nextTracking.outgoing ||
entry.tracking.xp !== nextTracking.xp ||
entry.tracking.currency !== nextTracking.currency
entry.tracking.currency !== nextTracking.currency ||
entry.tracking.encounters !== nextTracking.encounters
) {
entry.tracking = nextTracking;
dirty = true;
}
if (entry.chat.hp !== nextChat.hp || entry.chat.xp !== nextChat.xp || entry.chat.currency !== nextChat.currency) {
if (
entry.chat.hp !== nextChat.hp ||
entry.chat.outgoing !== nextChat.outgoing ||
entry.chat.xp !== nextChat.xp ||
entry.chat.currency !== nextChat.currency ||
entry.chat.encounters !== nextChat.encounters ||
entry.chat.chatAll !== nextChat.chatAll
) {
entry.chat = nextChat;
dirty = true;
}
@@ -2142,6 +2284,109 @@ class TrackingLedgerConfig extends FormApplication {
TrackingLedgerConfig._lastPage = this._page;
this.render(false);
});
html.find("[data-action=\"toggle-all\"]").on("change", async (event) => {
const target = event.currentTarget.dataset.target;
const value = event.currentTarget.checked;
if (!target) return;
const settings = getSettingsCache();
let dirty = false;
for (const [actorId, entry] of Object.entries(settings)) {
if (!entry.tracking || !entry.chat) continue;
switch (target) {
case "tracking.hp":
if (entry.tracking.hp !== value) {
entry.tracking.hp = value;
dirty = true;
}
break;
case "tracking.outgoing":
if (entry.tracking.outgoing !== value) {
entry.tracking.outgoing = value;
dirty = true;
}
break;
case "tracking.currency":
if (entry.tracking.currency !== value) {
entry.tracking.currency = value;
dirty = true;
}
break;
case "tracking.encounters":
if (entry.tracking.encounters !== value) {
entry.tracking.encounters = value;
dirty = true;
}
break;
case "chat.chatAll":
if (
entry.chat.chatAll !== value ||
entry.chat.hp !== value ||
entry.chat.outgoing !== value ||
entry.chat.currency !== value ||
entry.chat.encounters !== value
) {
entry.chat.chatAll = value;
entry.chat.hp = value;
entry.chat.outgoing = value;
entry.chat.currency = value;
entry.chat.encounters = value;
entry.chat.xp = value;
dirty = true;
}
break;
}
}
if (dirty) {
await saveActorSettings(settings);
this.render(false);
}
});
html.find("[data-action=\"clear-all-history\"]").on("click", async (event) => {
event.preventDefault();
const confirmed = await Dialog.confirm({
title: "Clear All Histories",
content: "<p>Remove all stored ledger history for all actors?</p>",
yes: () => true,
no: () => false,
defaultYes: false,
});
if (!confirmed) return;
try {
const actors = collectAllActorDocuments();
const tokens = collectAllTokens();
for (const actor of actors) await clearDocumentHistory(actor);
for (const token of tokens) await clearDocumentHistory(token);
ui.notifications?.info?.("History cleared for all actors.");
} catch (err) {
console.error("[GowlersTracking] Failed to clear all histories", err);
ui.notifications?.error?.("Failed to clear all histories; see console.");
}
});
html.find("[data-action=\"clear-history\"]").on("click", async (event) => {
event.preventDefault();
const actorId = event.currentTarget.dataset.actorId;
if (!actorId) return;
const actor = game.actors.get(actorId);
if (!actor) return;
const confirmed = await Dialog.confirm({
title: "Clear History",
content: `<p>Remove all stored ledger history (incoming, outgoing, XP, currency, encounters) for <strong>${actor.name}</strong>?</p>`,
yes: () => true,
no: () => false,
defaultYes: false,
});
if (!confirmed) return;
try {
await clearDocumentHistory(actor);
ui.notifications?.info?.(`History cleared for ${actor.name}.`);
} catch (err) {
console.error("[GowlersTracking] Failed to clear history", err);
ui.notifications?.error?.("Failed to clear history for actor; see console.");
}
});
}
static openForActor(actorId) {
@@ -2156,6 +2401,44 @@ class TrackingLedgerConfig extends FormApplication {
}
}
async function clearDocumentHistory(doc) {
if (!doc?.unsetFlag) return;
const flagKeys = [
...Object.values(STAT_CONFIGS).map((cfg) => cfg.flag),
DAMAGE_DEALT_FLAG,
ENCOUNTER_FLAG,
];
for (const key of flagKeys) {
try {
await doc.unsetFlag(FLAG_SCOPE, key);
} catch (err) {
console.warn(`[GowlersTracking] Failed to clear flag ${key} for ${doc.name ?? doc.id}`, err);
}
}
}
function collectAllActorDocuments() {
const actors = new Map();
for (const a of game.actors.contents ?? []) {
actors.set(a.id, a);
}
for (const scene of game.scenes ?? []) {
for (const token of scene.tokens ?? []) {
const a = token.actor;
if (a?.id && !actors.has(a.id)) actors.set(a.id, a);
}
}
return Array.from(actors.values());
}
function collectAllTokens() {
const tokens = [];
for (const scene of game.scenes ?? []) {
for (const token of scene.tokens ?? []) tokens.push(token);
}
return tokens;
}
/**
* Update encounter summary when combat starts
*/
@@ -2291,10 +2574,22 @@ function sendChatNotification(statId, actor, previous, nextValue, entry) {
const title = titles[statId] ?? statId.toUpperCase();
const prevText = config.formatValue(previous);
const nextText = entry.value;
const encounter = entry.encounterId ? entry.encounterId.slice(0, 8) : "N/A";
const source = entry.source ?? "Manual";
const partsHtml = entry.damageDetails?.parts?.length ? formatDamagePartsWithIcons(entry.damageDetails.parts) : "";
const details = entry.damageBreakdown || entry.breakdown || "";
const detailHtml = partsHtml || details ? `<div><strong>Details:</strong> ${partsHtml || details}</div>` : "";
const content = `
<strong>${title} Log</strong><br>
${actor.name}: ${prevText} -> ${nextText} (${entry.diff})<br>
<em>User:</em> ${entry.user ?? "System"}
<div style="font-family: var(--font-primary); font-size: 12px; line-height: 1.4;">
<div style="font-weight: bold; margin-bottom: 4px;">${title} Update</div>
<div><strong>Actor:</strong> ${actor.name}</div>
<div><strong>Value:</strong> ${prevText}${nextText} (${entry.diff})</div>
<div><strong>Source:</strong> ${source}</div>
<div><strong>Encounter:</strong> ${encounter}</div>
${detailHtml}
<div style="opacity: 0.8;"><strong>User:</strong> ${entry.user ?? "System"}</div>
</div>
`;
ChatMessage.create({

View File

@@ -34,21 +34,29 @@
<table class="tracking-ledger-table">
<thead>
<tr>
<th rowspan="2">Actor</th>
<th colspan="2">HP</th>
<th colspan="2">XP</th>
<th colspan="2">Currency</th>
</tr>
<tr>
<th>Track</th>
<th>Chat</th>
<th>Track</th>
<th>Chat</th>
<th>Track</th>
<th>Chat</th>
<th>Actor</th>
<th>Incoming (Track)</th>
<th>Outgoing (Track)</th>
<th>Currency (Track)</th>
<th>Encounters (Track)</th>
<th>Chat (All)</th>
<th>History</th>
</tr>
</thead>
<tbody>
<tr class="bulk-row" style="background:#f0f0f0;">
<td><strong>All actors</strong></td>
<td style="text-align:center;"><input type="checkbox" data-action="toggle-all" data-target="tracking.hp" {{#if allIncoming}}checked{{/if}}></td>
<td style="text-align:center;"><input type="checkbox" data-action="toggle-all" data-target="tracking.outgoing" {{#if allOutgoing}}checked{{/if}}></td>
<td style="text-align:center;"><input type="checkbox" data-action="toggle-all" data-target="tracking.currency" {{#if allCurrency}}checked{{/if}}></td>
<td style="text-align:center;"><input type="checkbox" data-action="toggle-all" data-target="tracking.encounters" {{#if allEncounters}}checked{{/if}}></td>
<td style="text-align:center;"><input type="checkbox" data-action="toggle-all" data-target="chat.chatAll" {{#if allChatAll}}checked{{/if}}></td>
<td style="text-align:center;">
<button type="button" class="clear-history-btn" data-action="clear-all-history" style="padding:4px 8px; display:inline-flex; align-items:center; gap:6px;">
<i class="fas fa-trash"></i><span>Clear All</span>
</button>
</td>
</tr>
{{#if actors.length}}
{{#each actors}}
<tr>
@@ -60,30 +68,33 @@
<input type="checkbox" name="actors.{{id}}.tracking.hp" {{#if tracking.hp}}checked{{/if}}>
</td>
<td>
<input type="checkbox" name="actors.{{id}}.chat.hp" {{#if chat.hp}}checked{{/if}}>
<input type="checkbox" name="actors.{{id}}.tracking.outgoing" {{#if tracking.outgoing}}checked{{/if}}>
</td>
<td>
<input type="checkbox" name="actors.{{id}}.tracking.xp" {{#if tracking.xp}}checked{{/if}}>
</td>
<td>
<input type="checkbox" name="actors.{{id}}.chat.xp" {{#if chat.xp}}checked{{/if}}>
</td>
<td>
<input type="checkbox" name="actors.{{id}}.tracking.currency" {{#if tracking.currency}}checked{{/if}}>
</td>
<td>
<input type="checkbox" name="actors.{{id}}.chat.currency" {{#if chat.currency}}checked{{/if}}>
</td>
</tr>
<td>
<input type="checkbox" name="actors.{{id}}.tracking.currency" {{#if tracking.currency}}checked{{/if}}>
</td>
<td>
<input type="checkbox" name="actors.{{id}}.tracking.encounters" {{#if tracking.encounters}}checked{{/if}}>
</td>
<td>
<input type="checkbox" name="actors.{{id}}.chatAll" {{#if chatAll}}checked{{/if}}>
</td>
<td style="text-align:center;">
<button type="button" class="clear-history-btn" data-action="clear-history" data-actor-id="{{id}}" style="padding:4px 8px; display:inline-flex; align-items:center; gap:6px;">
<i class="fas fa-trash"></i><span>Clear</span>
</button>
</td>
</tr>
{{/each}}
{{else}}
<tr>
<td colspan="7" class="empty">No actors match the current filter.</td>
<td colspan="11" class="empty">No actors match the current filter.</td>
</tr>
{{/if}}
</tbody>
</table>
<footer>
<button type="submit"><i class="fas fa-save"></i> Save</button>
<div style="flex:1; text-align:center; font-size:11px; opacity:0.7; margin-top:6px;">v{{moduleVersion}}</div>
</footer>
</form>