323 lines
9.5 KiB
JavaScript
323 lines
9.5 KiB
JavaScript
(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 "<p>No damage data recorded.</p>";
|
|
}
|
|
|
|
// 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 = `
|
|
<section style="padding: 10px; background: #222; border-radius: 5px; color: #fff; font-family: monospace;">
|
|
<h3 style="margin: 0 0 10px 0; color: #aaa; font-size: 14px;">⚔️ Damage Meter Report</h3>
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 12px;">
|
|
<thead>
|
|
<tr style="border-bottom: 2px solid #555;">
|
|
<th style="text-align: left; padding: 5px;">Actor</th>
|
|
<th style="text-align: right; padding: 5px;">Damage</th>
|
|
<th style="text-align: right; padding: 5px;">Hits</th>
|
|
<th style="text-align: right; padding: 5px;">Avg/Hit</th>
|
|
<th style="text-align: right; padding: 5px;">% Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
`;
|
|
|
|
sorted.forEach((actor, index) => {
|
|
const avgDamage =
|
|
actor.damageLog.length > 0
|
|
? Math.round(actor.totalDamage / actor.damageLog.length)
|
|
: 0;
|
|
const percentage =
|
|
totalTeamDamage > 0
|
|
? Math.round((actor.totalDamage / totalTeamDamage) * 100)
|
|
: 0;
|
|
|
|
const rowColor = index % 2 === 0 ? "#2a2a2a" : "#1f1f1f";
|
|
const rankColor = index === 0 ? "#ffd700" : "#ccc"; // Gold for #1
|
|
|
|
html += `
|
|
<tr style="background: ${rowColor}; border-bottom: 1px solid #444;">
|
|
<td style="padding: 5px; color: ${rankColor};">${
|
|
index + 1
|
|
}. ${actor.actorName}</td>
|
|
<td style="text-align: right; padding: 5px; color: #ff6b6b;">${
|
|
actor.totalDamage
|
|
}</td>
|
|
<td style="text-align: right; padding: 5px;">${
|
|
actor.damageLog.length
|
|
}</td>
|
|
<td style="text-align: right; padding: 5px;">${avgDamage}</td>
|
|
<td style="text-align: right; padding: 5px; color: #4ecdc4;">${percentage}%</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</tbody>
|
|
</table>
|
|
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #555; text-align: right; color: #aaa; font-size: 11px;">
|
|
Total Team Damage: <span style="color: #ff6b6b; font-weight: bold;">${totalTeamDamage}</span>
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Show current damage meter in UI
|
|
*/
|
|
function showDamageMeter() {
|
|
const content = formatDamageReport(meterState.state);
|
|
|
|
new Dialog({
|
|
title: "Damage Meter - Current Encounter",
|
|
content: content,
|
|
buttons: {
|
|
close: {
|
|
icon: '<i class="fas fa-times"></i>',
|
|
label: "Close"
|
|
}
|
|
},
|
|
default: "close",
|
|
width: 500
|
|
}).render(true);
|
|
}
|
|
|
|
// Provide public access
|
|
game.pf1.showDamageMeter = showDamageMeter;
|
|
|
|
const statusMessage = meterState.isActive
|
|
? `Damage meter tracking ACTIVE. Use 'game.pf1.showDamageMeter()' to view current stats.`
|
|
: `Damage meter tracking DISABLED.`;
|
|
|
|
ui.notifications.info(statusMessage);
|
|
console.log("[Damage Meter]", statusMessage);
|
|
})();
|