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 26b153c1..8d1d30c8 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 = "0.1.21"; +const MODULE_VERSION = "0.1.23"; const TRACK_SETTING = "actorSettings"; const FLAG_SCOPE = "world"; const MAX_HISTORY_ROWS = 100; @@ -372,42 +372,60 @@ async function initializeModule() { const pf1Flags = message.flags?.pf1 ?? {}; const metadata = pf1Flags.metadata ?? {}; - // Extract damage types from metadata or message content - const damageTypes = []; - - // Check for damage type data in metadata + // Legacy/simple metadata payloads may list damage types directly + const metadataTypes = []; if (metadata.damageTypes) { if (Array.isArray(metadata.damageTypes)) { - damageTypes.push(...metadata.damageTypes); - } else if (typeof metadata.damageTypes === 'string') { - damageTypes.push(metadata.damageTypes); + metadataTypes.push(...metadata.damageTypes.filter(Boolean)); + } else if (typeof metadata.damageTypes === "string") { + metadataTypes.push(metadata.damageTypes); } } - // Try to parse damage types from message content (HTML) - if (message.content && damageTypes.length === 0) { - // Look for damage type patterns in HTML - const contentMatch = message.content.match(/]*>.*?(Untyped|Slashing|Piercing|Bludgeoning|Fire|Cold|Electricity|Acid|Sonic|Force|Negative|Positive|Water)[^<]*<\/li>/gi); - if (contentMatch) { - contentMatch.forEach(match => { - const type = match.replace(/<[^>]*>/g, '').trim(); - if (type && !damageTypes.includes(type)) { - damageTypes.push(type); - } - }); + // Damage parts from PF1 metadata rolls (preferred) + const damageParts = []; + const attacks = metadata?.rolls?.attacks ?? []; + for (const attack of attacks) { + const damages = attack?.damage ?? []; + for (const dmg of damages) { + const part = normalizeDamagePart(dmg); + if (part) damageParts.push(part); } } + // Fallback: PF1 forwards instances in options (contains per-type totals) + if (!damageParts.length && Array.isArray(options.instances)) { + for (const inst of options.instances) { + const part = normalizeDamageInstance(inst); + if (part) damageParts.push(part); + } + } + + // Aggregate types for quick reference + const allTypes = Array.from( + new Set( + [ + ...damageParts.flatMap((part) => part.allTypes ?? []), + ...metadataTypes, + ] + ) + ).filter(Boolean); + // Extract critical hit flag const isCritical = options?.isCritical ?? false; - const critMultiplier = options?.critMult ?? 1; + const critMultiplier = options?.critMult ?? (metadata.config?.critMult ?? 1); // Extract nonlethal flag const asNonlethal = options?.asNonlethal ?? false; - // Try to extract damage breakdown from rolls + // Build a human-readable breakdown from the parts let breakdown = null; - if (message.rolls && message.rolls.length > 0) { + if (damageParts.length) { + breakdown = damageParts.map((part) => formatDamagePart(part)).filter(Boolean).join("; "); + } else if (metadataTypes.length) { + breakdown = metadataTypes.join(", "); + } else if (message.rolls && message.rolls.length > 0) { + // Legacy fallback: use the first roll formula try { const roll = message.rolls[0]; breakdown = roll.formula ?? roll.toString?.() ?? null; @@ -429,11 +447,12 @@ async function initializeModule() { } return { - types: damageTypes.length > 0 ? damageTypes : null, + types: allTypes.length > 0 ? allTypes : null, isCritical, critMultiplier, asNonlethal, breakdown, + parts: damageParts, }; } @@ -1027,6 +1046,7 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op let source = "Manual"; let sourceDetails = ""; let damageBreakdown = ""; + let matchedMessage = null; // For HP damage, search recent messages for matching damage application if (statId === "hp" && diffValue < 0) { @@ -1034,7 +1054,6 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op const now = Date.now(); // Look for matching message in recent queue (within 4 seconds) - let matchedMessage = null; for (let i = ledgerState.recentMessages.length - 1; i >= 0; i--) { const msg = ledgerState.recentMessages[i]; const timeSinceMessage = now - msg.timestamp; @@ -1052,7 +1071,10 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op if (matchedMessage) { source = matchedMessage.source; - damageBreakdown = `${damageAmount} HP`; + damageBreakdown = + formatDamageBreakdown(matchedMessage.damageDetails, damageAmount) || + matchedMessage.damageDetails?.breakdown || + `${damageAmount} damage`; console.log(`[GowlersTracking] Using matched damage source:`, source); } } @@ -1091,9 +1113,13 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op source: source, encounterId: encounterId, damageBreakdown: damageBreakdown, + damageDetails: damageBreakdown ? matchedMessage?.damageDetails ?? null : null, }; console.log(`[GowlersTracking] History entry created for ${statId}:`, entry); + if (statId === "hp" && diffValue < 0) { + console.log("[GowlersTracking] Stored damage breakdown:", damageBreakdown || "(none)"); + } const existing = (await actor.getFlag(FLAG_SCOPE, config.flag)) ?? []; existing.unshift(entry); @@ -1152,6 +1178,114 @@ function formatCurrency(obj = {}, signed = false) { .join(" "); } +function normalizeDamagePart(roll) { + if (!roll || typeof roll !== "object") return null; + + const typeObj = roll.options?.damageType ?? {}; + const values = Array.isArray(typeObj.values) ? typeObj.values.filter(Boolean) : []; + const customTypes = typeof typeObj.custom === "string" + ? typeObj.custom.split(/[,/]+/).map((t) => t.trim()).filter(Boolean) + : []; + const materials = []; + if (Array.isArray(typeObj.materials)) materials.push(...typeObj.materials.filter(Boolean)); + if (typeObj.material) materials.push(typeObj.material); + + const rollType = roll.options?.type ?? null; + const total = Number.isFinite(roll.total) ? roll.total : null; + const formula = roll.formula ?? null; + + const isNonlethal = values.some(isNonlethalType) || + customTypes.some(isNonlethalType) || + rollType === "nonlethal" || + roll.options?.asNonlethal; + + const baseTypes = values.filter((t) => !isNonlethalType(t)); + const allTypes = [...baseTypes, ...customTypes, ...materials]; + if (isNonlethal) allTypes.push("nonlethal"); + + return { + total, + formula, + types: baseTypes, + customTypes, + materials, + isNonlethal, + rollType, + allTypes, + }; +} + +function normalizeDamageInstance(instance) { + if (!instance || typeof instance !== "object") return null; + + const totals = [instance.total, instance.value, instance.amount].filter((v) => Number.isFinite(v)); + const total = totals.length ? totals[0] : null; + const formula = instance.formula ?? null; + + const rawTypes = []; + if (Array.isArray(instance.types)) rawTypes.push(...instance.types.filter(Boolean)); + if (instance.type) rawTypes.push(instance.type); + + const customTypes = typeof instance.custom === "string" + ? instance.custom.split(/[,/]+/).map((t) => t.trim()).filter(Boolean) + : []; + + const materials = []; + if (Array.isArray(instance.materials)) materials.push(...instance.materials.filter(Boolean)); + if (instance.material) materials.push(instance.material); + + const rollType = instance.typeLabel ?? null; + const isNonlethal = !!instance.nonlethal || rawTypes.some(isNonlethalType) || customTypes.some(isNonlethalType); + const baseTypes = rawTypes.filter((t) => !isNonlethalType(t)); + const allTypes = [...baseTypes, ...customTypes, ...materials]; + if (isNonlethal) allTypes.push("nonlethal"); + + return { + total, + formula, + types: baseTypes, + customTypes, + materials, + isNonlethal, + rollType, + allTypes, + }; +} + +function formatDamagePart(part) { + if (!part) return ""; + const amount = Number.isFinite(part.total) ? `${part.total}` : (part.formula ?? "?"); + const baseType = part.types?.length ? part.types.join("/") : "untyped"; + + const extras = []; + if (part.customTypes?.length) extras.push(part.customTypes.join("/")); + if (part.materials?.length) extras.push(part.materials.join("/")); + if (part.rollType && part.rollType !== "normal") extras.push(part.rollType); + + let label = baseType; + if (extras.length) label += ` (${extras.join(", ")})`; + if (part.isNonlethal && !/nonlethal/i.test(label)) label += " (nonlethal)"; + + return `${amount} ${label}`.trim(); +} + +function formatDamageBreakdown(details, damageAmount) { + if (!details) return Number.isFinite(damageAmount) ? `${damageAmount} damage` : ""; + + if (Array.isArray(details.parts) && details.parts.length) { + const partsText = details.parts.map((part) => formatDamagePart(part)).filter(Boolean); + if (partsText.length) return partsText.join("; "); + } + + if (details.breakdown) return details.breakdown; + if (Number.isFinite(damageAmount)) return `${damageAmount} damage`; + return ""; +} + +function isNonlethalType(type) { + return String(type ?? "").toLowerCase() === "nonlethal"; +} + function checkboxValue(value, fallback = false) { if (value === undefined || value === null) return fallback; if (Array.isArray(value)) value = value[value.length - 1];