242 lines
7.3 KiB
JavaScript
242 lines
7.3 KiB
JavaScript
/**
|
|
* Activate HP tracking for a single actor.
|
|
* Drop this script into a Foundry macro.
|
|
*/
|
|
|
|
// Change this lookup (or set ACTOR_ID directly) to match the actor you want to monitor.
|
|
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
|
|
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here"; // e.g. replace string with actor id
|
|
const HP_PATH = "system.attributes.hp.value";
|
|
|
|
if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") {
|
|
return ui.notifications.warn("Set ACTOR_ID before running the macro.");
|
|
}
|
|
|
|
game.pf1 ??= {};
|
|
game.pf1.hpLogger ??= {};
|
|
game.pf1.hpLogger.sources ??= new Map();
|
|
game.pf1.hpLogger.current ??= {};
|
|
|
|
const logKey = `pf1-hp-log-${ACTOR_ID}`;
|
|
|
|
// Remove existing hook for the same actor so re-running stays idempotent.
|
|
if (game.pf1.hpLogger[logKey]) {
|
|
Hooks.off("updateActor", game.pf1.hpLogger[logKey]);
|
|
delete game.pf1.hpLogger[logKey];
|
|
}
|
|
|
|
ensureDamageSourceTracking();
|
|
primeHpSnapshot(game.actors.get(ACTOR_ID) ?? TARGET_ACTOR);
|
|
|
|
game.pf1.hpLogger[logKey] = Hooks.on("updateActor", (actor, change, options, userId) => {
|
|
if (actor.id !== ACTOR_ID) return;
|
|
|
|
const newHP = readHpValue(actor);
|
|
if (newHP === null) return;
|
|
|
|
const previous = game.pf1.hpLogger.current[ACTOR_ID];
|
|
if (previous === undefined) {
|
|
game.pf1.hpLogger.current[ACTOR_ID] = newHP;
|
|
return;
|
|
}
|
|
if (Object.is(newHP, previous)) return; // No HP change.
|
|
|
|
const diff = newHP - previous;
|
|
const diffText = diff >= 0 ? `+${diff}` : `${diff}`;
|
|
const user = game.users.get(userId);
|
|
const source = consumeDamageSource(actor.id) ?? inferManualSource(diff);
|
|
const sourceLine = source ? `<br><em>Source: ${source}</em>` : "";
|
|
|
|
ChatMessage.create({
|
|
content: `<strong>HP Monitor</strong><br>${actor.name} HP: ${previous} -> ${newHP} (${diffText})${user ? ` by ${user.name}` : ""}${sourceLine}`,
|
|
speaker: { alias: "System" },
|
|
});
|
|
|
|
game.pf1.hpLogger.current[ACTOR_ID] = newHP;
|
|
});
|
|
|
|
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
|
ui.notifications.info(`HP change hook active for ${actorName}.`);
|
|
|
|
/**
|
|
* Consume and clear the stored damage source for this actor, if any.
|
|
* @param {string} actorId
|
|
*/
|
|
function consumeDamageSource(actorId) {
|
|
const entry = game.pf1.hpLogger.sources.get(actorId);
|
|
if (!entry) return null;
|
|
game.pf1.hpLogger.sources.delete(actorId);
|
|
return entry.label;
|
|
}
|
|
|
|
/**
|
|
* Provide a fallback label when the change was manual or unidentified.
|
|
* @param {number} diff
|
|
*/
|
|
function inferManualSource(diff) {
|
|
if (!Number.isFinite(diff) || diff === 0) return null;
|
|
return diff > 0 ? "Manual healing" : "Manual damage";
|
|
}
|
|
|
|
/**
|
|
* Ensure ActorPF.applyDamage is wrapped so we can capture the originating chat card.
|
|
*/
|
|
function ensureDamageSourceTracking() {
|
|
const state = game.pf1.hpLogger;
|
|
if (state.applyDamageWrapped) return;
|
|
|
|
const ActorPF = pf1?.documents?.actor?.ActorPF;
|
|
if (!ActorPF?.applyDamage) return;
|
|
|
|
const original = ActorPF.applyDamage;
|
|
ActorPF.applyDamage = async function wrappedApplyDamage(value, options = {}) {
|
|
try {
|
|
noteDamageSource(value, options);
|
|
} catch (err) {
|
|
console.warn("HP Logger | Failed to record damage source", err);
|
|
}
|
|
return original.call(this, value, options);
|
|
};
|
|
|
|
state.applyDamageWrapped = true;
|
|
}
|
|
|
|
/**
|
|
* Record the best-guess label for the HP change if the tracked actor is among the targets.
|
|
* @param {number} value
|
|
* @param {object} options
|
|
*/
|
|
function noteDamageSource(value, options) {
|
|
const actors = resolveActorTargets(options?.targets);
|
|
if (!actors.some((a) => a?.id === ACTOR_ID)) return;
|
|
|
|
const label = buildSourceLabel(value, options);
|
|
if (!label) return;
|
|
game.pf1.hpLogger.sources.set(ACTOR_ID, { label, ts: Date.now() });
|
|
}
|
|
|
|
/**
|
|
* Try to describe where the HP change originated.
|
|
* @param {number} value
|
|
* @param {object} options
|
|
*/
|
|
function buildSourceLabel(value, options = {}) {
|
|
const info = options.message?.flags?.pf1?.identifiedInfo ?? {};
|
|
const metadata = options.message?.flags?.pf1?.metadata ?? {};
|
|
const fromChatFlavor = options.message?.flavor?.trim();
|
|
|
|
const actorDoc = resolveActorFromMetadata(metadata);
|
|
const itemDoc = actorDoc ? resolveItemFromMetadata(actorDoc, metadata) : null;
|
|
const actorName = actorDoc?.name ?? options.message?.speaker?.alias ?? null;
|
|
const actionName = info.actionName ?? info.name ?? itemDoc?.name ?? fromChatFlavor ?? null;
|
|
|
|
let label = null;
|
|
if (actorName && actionName) label = `${actorName} -> ${actionName}`;
|
|
else if (actionName) label = actionName;
|
|
else if (actorName) label = actorName;
|
|
else label = value < 0 ? "Healing" : "Damage";
|
|
|
|
if (options.isCritical) label += " (Critical)";
|
|
if (options.asNonlethal) label += " [Nonlethal]";
|
|
|
|
return label;
|
|
}
|
|
|
|
/**
|
|
* Store the actor's current HP so the first change has a baseline.
|
|
* @param {Actor|null} actor
|
|
*/
|
|
function primeHpSnapshot(actor) {
|
|
const hp = readHpValue(actor);
|
|
if (hp === null) return;
|
|
game.pf1.hpLogger.current[ACTOR_ID] = hp;
|
|
}
|
|
|
|
/**
|
|
* Resolve actors targeted by the damage application.
|
|
* Falls back to currently controlled tokens if no explicit targets were supplied.
|
|
* @param {Array<Actor|Token>} targets
|
|
* @returns {Actor[]}
|
|
*/
|
|
function resolveActorTargets(targets) {
|
|
let list = [];
|
|
if (Array.isArray(targets) && targets.length) list = targets;
|
|
else list = canvas?.tokens?.controlled ?? [];
|
|
|
|
return list
|
|
.map((entry) => {
|
|
if (!entry) return null;
|
|
if (entry instanceof Actor) return entry;
|
|
if (entry.actor) return entry.actor;
|
|
if (entry.document?.actor) return entry.document.actor;
|
|
return null;
|
|
})
|
|
.filter((actor) => actor instanceof Actor && actor.id);
|
|
}
|
|
|
|
/**
|
|
* Read HP from the actor using several possible data paths.
|
|
* @param {Actor|null} actor
|
|
* @returns {number|null}
|
|
*/
|
|
function readHpValue(actor) {
|
|
if (!actor) return null;
|
|
|
|
const candidates = [
|
|
HP_PATH,
|
|
HP_PATH.replace(/^system\./, "data."),
|
|
HP_PATH.replace(/^system\./, ""),
|
|
];
|
|
|
|
for (const path of candidates) {
|
|
const value = foundry.utils.getProperty(actor, path);
|
|
if (value !== undefined) {
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) ? numeric : null;
|
|
}
|
|
if (actor.system) {
|
|
const trimmed = path.startsWith("system.") ? path.slice(7) : path;
|
|
const systemValue = foundry.utils.getProperty(actor.system, trimmed);
|
|
if (systemValue !== undefined) {
|
|
const numeric = Number(systemValue);
|
|
return Number.isFinite(numeric) ? numeric : null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Fetch the actor referenced in the chat card metadata, if any.
|
|
* @param {object} metadata
|
|
* @returns {Actor|null}
|
|
*/
|
|
function resolveActorFromMetadata(metadata = {}) {
|
|
if (!metadata.actor) return null;
|
|
|
|
if (typeof fromUuidSync === "function") {
|
|
try {
|
|
const doc = fromUuidSync(metadata.actor);
|
|
if (doc instanceof Actor) return doc;
|
|
} catch (err) {
|
|
console.warn("HP Logger | Failed to resolve actor UUID", err);
|
|
}
|
|
}
|
|
|
|
// Fallback: assume uuid ends with actor id
|
|
const id = metadata.actor.split(".").pop();
|
|
return game.actors.get(id) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Fetch the item referenced in the chat card metadata from the given actor.
|
|
* @param {Actor} actor
|
|
* @param {object} metadata
|
|
* @returns {Item|null}
|
|
*/
|
|
function resolveItemFromMetadata(actor, metadata = {}) {
|
|
if (!metadata.item || !(actor?.items instanceof Collection)) return null;
|
|
return actor.items.get(metadata.item) ?? null;
|
|
}
|