(async () => { /* * Activate damage tracking meter for encounters - similar to WoW Damage Meter * Tracks total damage dealt by each actor during active combat * Stores data in actor flags for persistence during encounter * Optimized for PF1e damage roll detection */ const FLAG_SCOPE = "world"; const FLAG_KEY = "pf1DamageHistory"; const MODULE_ID = "pf1-damage-meter"; const MAX_DAMAGE_RECORDS = 200; // Initialize global state game.pf1 ??= {}; game.pf1.damageMeter ??= { state: {}, // actorId -> { totalDamage, damageLog: [] } hooks: {}, // Store hook IDs for cleanup currentCombat: null, // Current active combat ID isActive: false }; const meterState = game.pf1.damageMeter; const logKey = `pf1-damage-meter`; // Check if already active - toggle off if (meterState.hooks[logKey]) { Hooks.off("createChatMessage", meterState.hooks[logKey].createMessage); Hooks.off("combatStart", meterState.hooks[logKey].combatStart); Hooks.off("combatEnd", meterState.hooks[logKey].combatEnd); delete meterState.hooks[logKey]; meterState.isActive = false; ui.notifications.info("Damage meter tracking disabled."); return; } // Enable damage meter meterState.hooks[logKey] = {}; /** * Extract damage from PF1e attack metadata * Handles both normal and critical damage */ function extractPF1eDamage(chatMessage) { try { const metadata = chatMessage.flags?.pf1?.metadata?.rolls?.attacks; if (!metadata || !Array.isArray(metadata)) return 0; let totalDamage = 0; // Sum all damage from all attacks in this message metadata.forEach(attack => { // Normal damage rolls if (Array.isArray(attack.damage)) { attack.damage.forEach(roll => { totalDamage += roll.total || 0; }); } // Critical damage rolls (in addition to normal) if (Array.isArray(attack.critDamage)) { attack.critDamage.forEach(roll => { totalDamage += roll.total || 0; }); } }); return totalDamage; } catch (err) { console.warn("[Damage Meter] Error extracting PF1e damage:", err); return 0; } } /** * Hook into chat message creation to capture damage rolls */ meterState.hooks[logKey].createMessage = Hooks.on( "createChatMessage", async (chatMessage, options, userId) => { // Skip if no active combat if (!game.combat?.active) return; // Get the actor who made this roll const actor = chatMessage.actor; if (!actor) return; // Try PF1e damage detection first const damageTotal = extractPF1eDamage(chatMessage); // Fallback: check for other roll metadata if (damageTotal === 0 && chatMessage.rolls?.length > 0) { const roll = chatMessage.rolls[0]; if ( roll.formula && /damage|harmful|d\d+/i.test(roll.formula) && roll.total > 0 ) { // Potential damage roll but not PF1e format // Only track if it looks like damage (has explicit "damage" keyword) if (/damage/i.test(roll.formula)) { const potential = roll.total; if (potential > 0) { recordDamage(actor, potential, roll.formula); } } } return; } if (damageTotal > 0) { recordDamage(actor, damageTotal, "PF1e Attack"); } } ); /** * Record damage for an actor */ async function recordDamage(actor, damageTotal, source = "Unknown") { // Initialize actor damage state if needed if (!meterState.state[actor.id]) { meterState.state[actor.id] = { actorName: actor.name, totalDamage: 0, damageLog: [] }; } const actorData = meterState.state[actor.id]; actorData.totalDamage += damageTotal; // Add entry to damage log actorData.damageLog.unshift({ timestamp: Date.now(), damage: damageTotal, source: source, roundNumber: game.combat.round }); // Keep log size manageable if (actorData.damageLog.length > MAX_DAMAGE_RECORDS) { actorData.damageLog = actorData.damageLog.slice(0, MAX_DAMAGE_RECORDS); } // Persist to actor flag await persistDamageData(actor, actorData); } /** * Reset damage tracking when combat starts */ meterState.hooks[logKey].combatStart = Hooks.on("combatStart", (combat) => { meterState.currentCombat = combat.id; meterState.state = {}; // Reset for new encounter ui.notifications.info("Damage meter tracking started for new encounter."); }); /** * Save damage stats when combat ends */ meterState.hooks[logKey].combatEnd = Hooks.on( "combatEnd", async (combat) => { const damageReport = formatDamageReport(meterState.state); // Persist final damage data to chat if (Object.keys(meterState.state).length > 0) { await ChatMessage.create({ content: damageReport, flags: { [MODULE_ID]: { combatId: combat.id } } }); // Save damage stats to actors for (const [actorId, data] of Object.entries(meterState.state)) { const actor = game.actors.get(actorId); if (actor) { await persistDamageData(actor, data); } } } meterState.state = {}; ui.notifications.info("Damage meter encounter ended. Stats saved to chat."); } ); meterState.isActive = true; /** * Persist damage data to actor flags */ async function persistDamageData(actor, data) { try { const existing = (await actor.getFlag(FLAG_SCOPE, FLAG_KEY)) ?? []; // Add new encounter entry existing.unshift({ timestamp: Date.now(), totalDamage: data.totalDamage, damageCount: data.damageLog.length, combatId: game.combat?.id || "debug", roundNumber: game.combat?.round || 0 }); // Keep recent encounters if (existing.length > 50) { existing.splice(50); } await actor.setFlag(FLAG_SCOPE, FLAG_KEY, existing); } catch (err) { console.error("Damage Meter | Failed to persist data:", err); } } /** * Format damage data for display */ function formatDamageReport(state) { if (Object.keys(state).length === 0) { return "
No damage data recorded.
"; } // Sort by total damage (descending) const sorted = Object.values(state).sort( (a, b) => b.totalDamage - a.totalDamage ); const totalTeamDamage = sorted.reduce( (sum, actor) => sum + actor.totalDamage, 0 ); let html = `| Actor | Damage | Hits | Avg/Hit | % Total |
|---|---|---|---|---|
| ${ index + 1 }. ${actor.actorName} | ${ actor.totalDamage } | ${ actor.damageLog.length } | ${avgDamage} | ${percentage}% |