diff --git a/src/macros_new/gowlers-tracking-ledger/foundry.gowlershome.dyndns.org/modules/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js b/src/macros_new/gowlers-tracking-ledger/foundry.gowlershome.dyndns.org/modules/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js index 83eb7c7b..a3534349 100644 --- a/src/macros_new/gowlers-tracking-ledger/foundry.gowlershome.dyndns.org/modules/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js +++ b/src/macros_new/gowlers-tracking-ledger/foundry.gowlershome.dyndns.org/modules/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js @@ -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 = ''; 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 = ``; + const modeToggle = ` +
+ + +
`; + 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) || `
`; + 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) || + `
`; + const bar = `
${innerBar}
`; const avatar = t.img ? `` : ""; return ` ${idx + 1} ${avatar}${t.name} ${t.total} + ${t.healing} ${t.hits} ${bar} @@ -1789,26 +1886,23 @@ function buildDamageMeterContent() { } ) .join("") - : `No damage recorded${encounterId ? " for this encounter" : ""}.`; - - const npcToggle = ``; + : `No damage recorded${encounterId ? " for this encounter" : ""}.`; return `
Damage Meter
-
- ${encounterLabel} - ${npcToggle} +
+
${modeToggle}
+
${npcToggle}
+
${encounterLabel}
- + + @@ -1821,6 +1915,7 @@ function buildDamageMeterContent() { + @@ -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: "

Remove all stored ledger history for all actors?

", + 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: `

Remove all stored ledger history (incoming, outgoing, XP, currency, encounters) for ${actor.name}?

`, + 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 ? `
Details: ${partsHtml || details}
` : ""; + const content = ` - ${title} Log
- ${actor.name}: ${prevText} -> ${nextText} (${entry.diff})
- User: ${entry.user ?? "System"} +
+
${title} Update
+
Actor: ${actor.name}
+
Value: ${prevText} → ${nextText} (${entry.diff})
+
Source: ${source}
+
Encounter: ${encounter}
+ ${detailHtml} +
User: ${entry.user ?? "System"}
+
`; ChatMessage.create({ diff --git a/src/macros_new/gowlers-tracking-ledger/templates/config.hbs b/src/macros_new/gowlers-tracking-ledger/templates/config.hbs index 00d5a2cb..3f697ee6 100644 --- a/src/macros_new/gowlers-tracking-ledger/templates/config.hbs +++ b/src/macros_new/gowlers-tracking-ledger/templates/config.hbs @@ -34,21 +34,29 @@
# ActorTotalDamageHealing Hits Last Breakdown
Total ${grandTotal}${grandHealing}
- - - - - - - - - - - - + + + + + + + + + + + + + + + + {{#if actors.length}} {{#each actors}} @@ -60,30 +68,33 @@ - - - - - + + + + + {{/each}} {{else}} - + {{/if}}
ActorHPXPCurrency
TrackChatTrackChatTrackChatActorIncoming (Track)Outgoing (Track)Currency (Track)Encounters (Track)Chat (All)History
All actors + +
- + - - - - - - - -
+ + + + + + + +
No actors match the current filter.No actors match the current filter.