Files
FoundryVTT/src/macro_activate-hp-history-tab.js
centron\schwoerer f054a31b20 zischenstand
2025-11-14 14:52:43 +01:00

209 lines
6.2 KiB
JavaScript

/**
* Activate HP history tracking that stores entries in flags only.
* Pair with macro_enable-history-tab.js to view the data inside a History tab.
*/
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here";
const HP_PATH = "system.attributes.hp.value";
const FLAG_SCOPE = "world";
const FLAG_KEY = "pf1HpHistory";
const UPDATE_CONTEXT_FLAG = "hpHistoryFlags";
const MAX_ROWS = 50;
if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") {
return ui.notifications.warn("Set ACTOR_ID before running the macro.");
}
game.pf1 ??= {};
game.pf1.hpHistoryFlags ??= { sources: new Map(), current: {}, hooks: {} };
const state = game.pf1.hpHistoryFlags;
const logKey = `pf1-hp-history-flags-${ACTOR_ID}`;
if (state.hooks[logKey]) {
Hooks.off("updateActor", state.hooks[logKey]);
delete state.hooks[logKey];
}
ensureDamageSourceTracking();
primeSnapshot(game.actors.get(ACTOR_ID) ?? TARGET_ACTOR);
state.hooks[logKey] = Hooks.on("updateActor", (actor, change, options, userId) => {
if (actor.id !== ACTOR_ID) return;
if (options?.[UPDATE_CONTEXT_FLAG]) return;
const newHP = readHpValue(actor);
if (newHP === null) return;
const previous = state.current[ACTOR_ID];
if (previous === undefined) {
state.current[ACTOR_ID] = newHP;
return;
}
if (Object.is(newHP, previous)) return;
const diff = newHP - previous;
const diffText = diff >= 0 ? `+${diff}` : `${diff}`;
const user = game.users.get(userId);
const source = consumeDamageSource(actor.id) ?? inferManualSource(diff);
state.current[ACTOR_ID] = newHP;
appendHistoryEntry(actor, {
hp: newHP,
diff: diffText,
user: user?.name ?? "System",
source: source ?? "",
}).catch((err) => console.error("HP History Flags | Failed to append entry", err));
});
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
ui.notifications.info(`HP flag history tracking active for ${actorName}.`);
async function appendHistoryEntry(actor, entry) {
const existing = (await actor.getFlag(FLAG_SCOPE, FLAG_KEY)) ?? [];
existing.unshift({
timestamp: Date.now(),
hp: entry.hp,
diff: entry.diff,
user: entry.user,
source: entry.source,
});
if (existing.length > MAX_ROWS) existing.splice(MAX_ROWS);
await actor.setFlag(FLAG_SCOPE, FLAG_KEY, existing);
}
function consumeDamageSource(actorId) {
const entry = state.sources.get(actorId);
if (!entry) return null;
state.sources.delete(actorId);
return entry.label;
}
function inferManualSource(diff) {
if (!Number.isFinite(diff) || diff === 0) return null;
return diff > 0 ? "Manual healing" : "Manual damage";
}
function ensureDamageSourceTracking() {
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 History Flags | Failed to record source", err);
}
return original.call(this, value, options);
};
state.applyDamageWrapped = true;
}
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;
state.sources.set(ACTOR_ID, { label, ts: Date.now() });
}
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;
}
function primeSnapshot(actor) {
const hp = readHpValue(actor);
if (hp === null) return;
state.current[ACTOR_ID] = hp;
}
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);
}
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;
}
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 History Flags | Failed to resolve actor UUID", err);
}
}
const id = metadata.actor.split(".").pop();
return game.actors.get(id) ?? null;
}
function resolveItemFromMetadata(actor, metadata = {}) {
if (!metadata.item || !(actor?.items instanceof Collection)) return null;
return actor.items.get(metadata.item) ?? null;
}