sthsth
This commit is contained in:
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Activate currency history tracking that stores entries in flags only.
|
||||
*/
|
||||
|
||||
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
|
||||
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here";
|
||||
const CURRENCY_PATH = "system.currency";
|
||||
const FLAG_SCOPE = "world";
|
||||
const FLAG_KEY = "pf1CurrencyHistory";
|
||||
const MAX_ROWS = 50;
|
||||
const COIN_ORDER = ["pp", "gp", "sp", "cp"];
|
||||
|
||||
if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") {
|
||||
return ui.notifications.warn("Set ACTOR_ID before running the macro.");
|
||||
}
|
||||
|
||||
game.pf1 ??= {};
|
||||
game.pf1.currencyHistoryFlags ??= { current: {}, hooks: {} };
|
||||
const state = game.pf1.currencyHistoryFlags;
|
||||
|
||||
const logKey = `pf1-currency-history-flags-${ACTOR_ID}`;
|
||||
if (state.hooks[logKey]) {
|
||||
Hooks.off("updateActor", state.hooks[logKey]);
|
||||
delete state.hooks[logKey];
|
||||
}
|
||||
|
||||
primeSnapshot(game.actors.get(ACTOR_ID) ?? TARGET_ACTOR);
|
||||
|
||||
state.hooks[logKey] = Hooks.on("updateActor", (actor, change, options, userId) => {
|
||||
if (actor.id !== ACTOR_ID) return;
|
||||
|
||||
const newCurrency = readCurrency(actor);
|
||||
if (!newCurrency) return;
|
||||
|
||||
const previous = state.current[ACTOR_ID];
|
||||
if (!previous) {
|
||||
state.current[ACTOR_ID] = newCurrency;
|
||||
return;
|
||||
}
|
||||
if (currencyEquals(previous, newCurrency)) return;
|
||||
|
||||
const diff = diffCurrency(previous, newCurrency);
|
||||
const user = game.users.get(userId);
|
||||
|
||||
state.current[ACTOR_ID] = newCurrency;
|
||||
|
||||
appendHistoryEntry(actor, {
|
||||
value: formatCurrency(newCurrency),
|
||||
diff: formatCurrency(diff, true),
|
||||
user: user?.name ?? "System",
|
||||
}).catch((err) => console.error("Currency History Flags | Failed to append entry", err));
|
||||
});
|
||||
|
||||
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
||||
ui.notifications.info(`Currency 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(),
|
||||
value: entry.value,
|
||||
diff: entry.diff,
|
||||
user: entry.user,
|
||||
});
|
||||
if (existing.length > MAX_ROWS) existing.splice(MAX_ROWS);
|
||||
|
||||
await actor.setFlag(FLAG_SCOPE, FLAG_KEY, existing);
|
||||
}
|
||||
|
||||
function primeSnapshot(actor) {
|
||||
const currency = readCurrency(actor);
|
||||
if (!currency) return;
|
||||
state.current[ACTOR_ID] = currency;
|
||||
}
|
||||
|
||||
function readCurrency(actor) {
|
||||
if (!actor) return null;
|
||||
const base = foundry.utils.getProperty(actor, CURRENCY_PATH) ?? actor.system?.currency;
|
||||
if (!base) return null;
|
||||
const clone = {};
|
||||
for (const coin of COIN_ORDER) clone[coin] = Number(base[coin] ?? 0);
|
||||
return clone;
|
||||
}
|
||||
|
||||
function currencyEquals(a, b) {
|
||||
return COIN_ORDER.every((coin) => (a[coin] ?? 0) === (b[coin] ?? 0));
|
||||
}
|
||||
|
||||
function diffCurrency(prev, next) {
|
||||
const diff = {};
|
||||
for (const coin of COIN_ORDER) diff[coin] = (next[coin] ?? 0) - (prev[coin] ?? 0);
|
||||
return diff;
|
||||
}
|
||||
|
||||
function formatCurrency(obj, skipZero = false) {
|
||||
return COIN_ORDER.map((coin) => obj[coin] ?? 0)
|
||||
.map((value, idx) => {
|
||||
if (skipZero && value === 0) return null;
|
||||
const label = COIN_ORDER[idx];
|
||||
const v = skipZero && value > 0 ? `+${value}` : `${value}`;
|
||||
return `${label}:${v}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
/**
|
||||
* Track currency changes for a single actor and store them in ===Currency History=== notes.
|
||||
*/
|
||||
|
||||
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
|
||||
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here";
|
||||
const CURRENCY_PATH = "system.currency";
|
||||
const UPDATE_CONTEXT_FLAG = "currencyHistory";
|
||||
const FLAG_SCOPE = "world";
|
||||
const FLAG_KEY = "pf1CurrencyHistory";
|
||||
const BLOCK_NAME = "pf1-currency-history";
|
||||
const MAX_ROWS = 50;
|
||||
const BLOCK_START = "<!-- CURRENCY_HISTORY_START -->";
|
||||
const BLOCK_END = "<!-- CURRENCY_HISTORY_END -->";
|
||||
const COIN_ORDER = ["pp", "gp", "sp", "cp"];
|
||||
|
||||
if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") {
|
||||
return ui.notifications.warn("Set ACTOR_ID before running the macro.");
|
||||
}
|
||||
|
||||
game.pf1 ??= {};
|
||||
game.pf1.currencyHistory ??= {};
|
||||
const state = game.pf1.currencyHistory;
|
||||
state.sources ??= new Map();
|
||||
state.current ??= {};
|
||||
state.hooks ??= {};
|
||||
|
||||
const logKey = `pf1-currency-history-${ACTOR_ID}`;
|
||||
|
||||
if (state.hooks[logKey]) {
|
||||
Hooks.off("updateActor", state.hooks[logKey]);
|
||||
delete state.hooks[logKey];
|
||||
}
|
||||
|
||||
ensureDamageSourceTracking();
|
||||
primeCurrencySnapshot(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 newCurrency = readCurrencyValue(actor);
|
||||
if (!newCurrency) return;
|
||||
|
||||
const previous = state.current[ACTOR_ID];
|
||||
if (!previous) {
|
||||
state.current[ACTOR_ID] = newCurrency;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currencyEquals(previous, newCurrency)) return;
|
||||
|
||||
const diff = diffCurrency(previous, newCurrency);
|
||||
const diffText = formatCurrency(diff, true);
|
||||
const user = game.users.get(userId);
|
||||
const source = consumeDamageSource(actor.id) ?? "Manual change";
|
||||
|
||||
state.current[ACTOR_ID] = newCurrency;
|
||||
|
||||
appendHistoryRow(actor, {
|
||||
value: formatCurrency(newCurrency),
|
||||
diffText,
|
||||
user,
|
||||
source,
|
||||
snapshot: newCurrency,
|
||||
}).catch((err) => console.error("Currency History | Failed to append entry", err));
|
||||
});
|
||||
|
||||
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
||||
ui.notifications.info(`Currency history tracking active for ${actorName}.`);
|
||||
|
||||
async function appendHistoryRow(actor, entry) {
|
||||
const existing = (await actor.getFlag(FLAG_SCOPE, FLAG_KEY)) ?? [];
|
||||
const newEntry = {
|
||||
timestamp: Date.now(),
|
||||
value: entry.value,
|
||||
diff: entry.diffText,
|
||||
user: entry.user?.name ?? "System",
|
||||
source: entry.source ?? "",
|
||||
};
|
||||
existing.unshift(newEntry);
|
||||
if (existing.length > MAX_ROWS) existing.splice(MAX_ROWS);
|
||||
|
||||
const notes = actor.system.details?.notes?.value ?? "";
|
||||
const block = renderHistoryBlock(existing);
|
||||
const updatedNotes = injectHistoryBlock(notes, block);
|
||||
|
||||
await actor.update(
|
||||
{
|
||||
[`flags.${FLAG_SCOPE}.${FLAG_KEY}`]: existing,
|
||||
"system.details.notes.value": updatedNotes,
|
||||
},
|
||||
{ [UPDATE_CONTEXT_FLAG]: true }
|
||||
);
|
||||
}
|
||||
|
||||
function renderHistoryBlock(entries) {
|
||||
const rows = entries
|
||||
.map(
|
||||
(e) => `
|
||||
<tr data-ts="${e.timestamp}">
|
||||
<td>${new Date(e.timestamp).toLocaleString()}</td>
|
||||
<td>${e.value}</td>
|
||||
<td>${e.diff}</td>
|
||||
<td>${e.user ?? ""}</td>
|
||||
<td>${e.source ?? ""}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const blockId = `${BLOCK_NAME}-${ACTOR_ID}`;
|
||||
return `
|
||||
${BLOCK_START}
|
||||
<section data-history-block="${BLOCK_NAME}" data-history-id="${blockId}">
|
||||
<details open>
|
||||
<summary>===Currency History=== (click to collapse)</summary>
|
||||
<div class="history-controls">
|
||||
<label>From <input type="date" data-filter="from"></label>
|
||||
<label>To <input type="date" data-filter="to"></label>
|
||||
</div>
|
||||
<table class="currency-history" data-currency-history="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Totals</th>
|
||||
<th>Δ</th>
|
||||
<th>User</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
<script type="text/javascript">
|
||||
(function(){
|
||||
const root = document.currentScript.closest('section[data-history-block="${BLOCK_NAME}"]');
|
||||
if (!root) return;
|
||||
const rows = Array.from(root.querySelectorAll("tbody tr"));
|
||||
const fromInput = root.querySelector('input[data-filter="from"]');
|
||||
const toInput = root.querySelector('input[data-filter="to"]');
|
||||
function applyFilters(){
|
||||
const from = fromInput?.value ? Date.parse(fromInput.value) : null;
|
||||
const to = toInput?.value ? Date.parse(toInput.value) + 86399999 : null;
|
||||
rows.forEach((row) => {
|
||||
const ts = Number(row.dataset.ts);
|
||||
const visible = (!from || ts >= from) && (!to || ts <= to);
|
||||
row.style.display = visible ? "" : "none";
|
||||
});
|
||||
}
|
||||
fromInput?.addEventListener("input", applyFilters);
|
||||
toInput?.addEventListener("input", applyFilters);
|
||||
applyFilters();
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
${BLOCK_END}`.trim();
|
||||
}
|
||||
|
||||
function injectHistoryBlock(notes, block) {
|
||||
const blockPattern = new RegExp(`${escapeRegExp(BLOCK_START)}[\\s\\S]*?${escapeRegExp(BLOCK_END)}`, "g");
|
||||
let cleaned = notes.replace(blockPattern, "");
|
||||
if (cleaned === notes) {
|
||||
const sectionPattern = new RegExp(`<section[^>]*data-history-block="${BLOCK_NAME}"[^>]*>[\\s\\S]*?<\\/section>`, "g");
|
||||
cleaned = notes.replace(sectionPattern, "");
|
||||
}
|
||||
cleaned = cleaned.trim();
|
||||
const separator = cleaned.length && !cleaned.endsWith("\n") ? "\n\n" : "";
|
||||
return `${cleaned}${separator}${block}`;
|
||||
}
|
||||
|
||||
function consumeDamageSource(actorId) {
|
||||
const entry = state.sources.get(actorId);
|
||||
if (!entry) return null;
|
||||
state.sources.delete(actorId);
|
||||
return entry.label;
|
||||
}
|
||||
|
||||
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("Currency History | 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;
|
||||
|
||||
if (actorName && actionName) return `${actorName} -> ${actionName}`;
|
||||
if (actionName) return actionName;
|
||||
if (actorName) return actorName;
|
||||
return null;
|
||||
}
|
||||
|
||||
function primeCurrencySnapshot(actor) {
|
||||
const currency = readCurrencyValue(actor);
|
||||
if (!currency) return;
|
||||
state.current[ACTOR_ID] = currency;
|
||||
}
|
||||
|
||||
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 readCurrencyValue(actor) {
|
||||
if (!actor) return null;
|
||||
const base = foundry.utils.getProperty(actor, CURRENCY_PATH) ?? actor.system?.currency;
|
||||
if (!base) return null;
|
||||
const clone = {};
|
||||
for (const coin of COIN_ORDER) clone[coin] = Number(base[coin] ?? 0);
|
||||
return clone;
|
||||
}
|
||||
|
||||
function currencyEquals(a, b) {
|
||||
return COIN_ORDER.every((coin) => (a[coin] ?? 0) === (b[coin] ?? 0));
|
||||
}
|
||||
|
||||
function diffCurrency(prev, next) {
|
||||
const diff = {};
|
||||
for (const coin of COIN_ORDER) diff[coin] = (next[coin] ?? 0) - (prev[coin] ?? 0);
|
||||
return diff;
|
||||
}
|
||||
|
||||
function formatCurrency(obj, skipZero = false) {
|
||||
return COIN_ORDER.map((coin) => obj[coin] ?? 0)
|
||||
.map((value, idx) => {
|
||||
if (skipZero && value === 0) return null;
|
||||
const label = COIN_ORDER[idx];
|
||||
const v = skipZero ? (value > 0 ? `+${value}` : `${value}`) : value;
|
||||
return `${label}:${v}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
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("Currency History | 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;
|
||||
}
|
||||
|
||||
function escapeRegExp(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
/**
|
||||
* Activate HP history tracking for a single actor.
|
||||
* Instead of sending chat messages, HP changes are written into an
|
||||
* ===HP History=== table inside the actor's Notes field.
|
||||
*/
|
||||
|
||||
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 UPDATE_CONTEXT_FLAG = "hpHistory";
|
||||
const FLAG_SCOPE = "world";
|
||||
const FLAG_KEY = "pf1HpHistory";
|
||||
const BLOCK_NAME = "pf1-hp-history";
|
||||
const MAX_ROWS = 50;
|
||||
const BLOCK_START = "<!-- HP_HISTORY_START -->";
|
||||
const BLOCK_END = "<!-- HP_HISTORY_END -->";
|
||||
|
||||
if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") {
|
||||
return ui.notifications.warn("Set ACTOR_ID before running the macro.");
|
||||
}
|
||||
|
||||
game.pf1 ??= {};
|
||||
game.pf1.hpHistory ??= {};
|
||||
const state = game.pf1.hpHistory;
|
||||
state.sources ??= new Map();
|
||||
state.current ??= {};
|
||||
state.hooks ??= {};
|
||||
|
||||
const logKey = `pf1-hp-history-${ACTOR_ID}`;
|
||||
|
||||
// Remove any previous hook for this actor so the macro is idempotent.
|
||||
if (state.hooks[logKey]) {
|
||||
Hooks.off("updateActor", state.hooks[logKey]);
|
||||
delete state.hooks[logKey];
|
||||
}
|
||||
|
||||
ensureDamageSourceTracking();
|
||||
primeHpSnapshot(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; // Ignore our own notes updates.
|
||||
|
||||
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;
|
||||
|
||||
appendHistoryRow(actor, {
|
||||
previous,
|
||||
newHP,
|
||||
diff,
|
||||
diffText,
|
||||
user,
|
||||
source,
|
||||
}).catch((err) => console.error("HP History | Failed to append entry", err));
|
||||
});
|
||||
|
||||
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
||||
ui.notifications.info(`HP history tracking active for ${actorName}.`);
|
||||
|
||||
/**
|
||||
* Append an entry to the Notes table.
|
||||
* @param {Actor} actor
|
||||
* @param {object} entry
|
||||
*/
|
||||
async function appendHistoryRow(actor, entry) {
|
||||
const existing = (await actor.getFlag(FLAG_SCOPE, FLAG_KEY)) ?? [];
|
||||
const newEntry = {
|
||||
timestamp: Date.now(),
|
||||
hp: entry.newHP,
|
||||
diff: entry.diffText,
|
||||
user: entry.user?.name ?? "System",
|
||||
source: entry.source ?? "",
|
||||
};
|
||||
existing.unshift(newEntry);
|
||||
if (existing.length > MAX_ROWS) existing.splice(MAX_ROWS);
|
||||
|
||||
const notes = actor.system.details?.notes?.value ?? "";
|
||||
const block = renderHistoryBlock(existing);
|
||||
const updatedNotes = injectHistoryBlock(notes, block);
|
||||
|
||||
await actor.update(
|
||||
{
|
||||
[`flags.${FLAG_SCOPE}.${FLAG_KEY}`]: existing,
|
||||
"system.details.notes.value": updatedNotes,
|
||||
},
|
||||
{ [UPDATE_CONTEXT_FLAG]: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume and clear the stored damage source for this actor, if any.
|
||||
* @param {string} actorId
|
||||
*/
|
||||
function consumeDamageSource(actorId) {
|
||||
const entry = state.sources.get(actorId);
|
||||
if (!entry) return null;
|
||||
state.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 originating chat data.
|
||||
*/
|
||||
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 | 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;
|
||||
state.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;
|
||||
state.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 History | Failed to resolve actor UUID", err);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the HP history heading and table HTML.
|
||||
* @param {Array<object>} entries
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderHistoryBlock(entries) {
|
||||
const rows = entries
|
||||
.map(
|
||||
(e) => `
|
||||
<tr data-ts="${e.timestamp}">
|
||||
<td>${new Date(e.timestamp).toLocaleString()}</td>
|
||||
<td>${e.hp}</td>
|
||||
<td>${e.diff}</td>
|
||||
<td>${e.user ?? ""}</td>
|
||||
<td>${e.source ?? ""}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const blockId = `${BLOCK_NAME}-${ACTOR_ID}`;
|
||||
const table = `
|
||||
${BLOCK_START}
|
||||
<section data-history-block="${BLOCK_NAME}" data-history-id="${blockId}">
|
||||
<details open>
|
||||
<summary>===HP History=== (click to collapse)</summary>
|
||||
<div class="history-controls">
|
||||
<label>From <input type="date" data-filter="from"></label>
|
||||
<label>To <input type="date" data-filter="to"></label>
|
||||
</div>
|
||||
<table class="hp-history" data-hp-history="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>HP</th>
|
||||
<th>Δ</th>
|
||||
<th>User</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
<script type="text/javascript">
|
||||
(function(){
|
||||
const root = document.currentScript.closest('section[data-history-block="${BLOCK_NAME}"]');
|
||||
if (!root) return;
|
||||
const rows = Array.from(root.querySelectorAll("tbody tr"));
|
||||
const fromInput = root.querySelector('input[data-filter="from"]');
|
||||
const toInput = root.querySelector('input[data-filter="to"]');
|
||||
function applyFilters(){
|
||||
const from = fromInput?.value ? Date.parse(fromInput.value) : null;
|
||||
const to = toInput?.value ? Date.parse(toInput.value) + 86399999 : null;
|
||||
rows.forEach((row) => {
|
||||
const ts = Number(row.dataset.ts);
|
||||
const visible = (!from || ts >= from) && (!to || ts <= to);
|
||||
row.style.display = visible ? "" : "none";
|
||||
});
|
||||
}
|
||||
fromInput?.addEventListener("input", applyFilters);
|
||||
toInput?.addEventListener("input", applyFilters);
|
||||
applyFilters();
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
${BLOCK_END}`.trim();
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
function injectHistoryBlock(notes, block) {
|
||||
const blockPattern = new RegExp(`${escapeRegExp(BLOCK_START)}[\\s\\S]*?${escapeRegExp(BLOCK_END)}`, "g");
|
||||
let cleaned = notes.replace(blockPattern, "");
|
||||
if (cleaned === notes) {
|
||||
const sectionPattern = new RegExp(`<section[^>]*data-history-block="${BLOCK_NAME}"[^>]*>[\\s\\S]*?<\\/section>`, "g");
|
||||
cleaned = notes.replace(sectionPattern, "");
|
||||
}
|
||||
cleaned = cleaned.trim();
|
||||
const separator = cleaned.length && !cleaned.endsWith("\n") ? "\n\n" : "";
|
||||
return `${cleaned}${separator}${block}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Activate XP history tracking that stores entries in flags only.
|
||||
*/
|
||||
|
||||
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
|
||||
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here";
|
||||
const XP_PATH = "system.details.xp.value";
|
||||
const FLAG_SCOPE = "world";
|
||||
const FLAG_KEY = "pf1XpHistory";
|
||||
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.xpHistoryFlags ??= { current: {}, hooks: {} };
|
||||
const state = game.pf1.xpHistoryFlags;
|
||||
|
||||
const logKey = `pf1-xp-history-flags-${ACTOR_ID}`;
|
||||
if (state.hooks[logKey]) {
|
||||
Hooks.off("updateActor", state.hooks[logKey]);
|
||||
delete state.hooks[logKey];
|
||||
}
|
||||
|
||||
primeSnapshot(game.actors.get(ACTOR_ID) ?? TARGET_ACTOR);
|
||||
|
||||
state.hooks[logKey] = Hooks.on("updateActor", (actor, change, options, userId) => {
|
||||
if (actor.id !== ACTOR_ID) return;
|
||||
|
||||
const newXP = readXpValue(actor);
|
||||
if (newXP === null) return;
|
||||
|
||||
const previous = state.current[ACTOR_ID];
|
||||
if (previous === undefined) {
|
||||
state.current[ACTOR_ID] = newXP;
|
||||
return;
|
||||
}
|
||||
if (Object.is(newXP, previous)) return;
|
||||
|
||||
const diff = newXP - previous;
|
||||
const diffText = diff >= 0 ? `+${diff}` : `${diff}`;
|
||||
const user = game.users.get(userId);
|
||||
|
||||
state.current[ACTOR_ID] = newXP;
|
||||
|
||||
appendHistoryEntry(actor, {
|
||||
value: newXP,
|
||||
diff: diffText,
|
||||
user: user?.name ?? "System",
|
||||
}).catch((err) => console.error("XP History Flags | Failed to append entry", err));
|
||||
});
|
||||
|
||||
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
||||
ui.notifications.info(`XP 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(),
|
||||
value: entry.value,
|
||||
diff: entry.diff,
|
||||
user: entry.user,
|
||||
});
|
||||
if (existing.length > MAX_ROWS) existing.splice(MAX_ROWS);
|
||||
|
||||
await actor.setFlag(FLAG_SCOPE, FLAG_KEY, existing);
|
||||
}
|
||||
|
||||
function primeSnapshot(actor) {
|
||||
const xp = readXpValue(actor);
|
||||
if (xp === null) return;
|
||||
state.current[ACTOR_ID] = xp;
|
||||
}
|
||||
|
||||
function readXpValue(actor) {
|
||||
if (!actor) return null;
|
||||
const direct = foundry.utils.getProperty(actor, XP_PATH);
|
||||
if (direct !== undefined) {
|
||||
const numeric = Number(direct);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
const sys = foundry.utils.getProperty(actor.system ?? {}, XP_PATH.replace(/^system\./, ""));
|
||||
if (sys !== undefined) {
|
||||
const numeric = Number(sys);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
/**
|
||||
* Track XP changes for a single actor and record them inside ===XP History=== notes.
|
||||
*/
|
||||
|
||||
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
|
||||
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here";
|
||||
const XP_PATH = "system.details.xp.value";
|
||||
const UPDATE_CONTEXT_FLAG = "xpHistory";
|
||||
const FLAG_SCOPE = "world";
|
||||
const FLAG_KEY = "pf1XpHistory";
|
||||
const BLOCK_NAME = "pf1-xp-history";
|
||||
const MAX_ROWS = 50;
|
||||
const BLOCK_START = "<!-- XP_HISTORY_START -->";
|
||||
const BLOCK_END = "<!-- XP_HISTORY_END -->";
|
||||
|
||||
if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") {
|
||||
return ui.notifications.warn("Set ACTOR_ID before running the macro.");
|
||||
}
|
||||
|
||||
game.pf1 ??= {};
|
||||
game.pf1.xpHistory ??= {};
|
||||
const state = game.pf1.xpHistory;
|
||||
state.sources ??= new Map();
|
||||
state.current ??= {};
|
||||
state.hooks ??= {};
|
||||
|
||||
const logKey = `pf1-xp-history-${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 newXP = readXpValue(actor);
|
||||
if (newXP === null) return;
|
||||
|
||||
const previous = state.current[ACTOR_ID];
|
||||
if (previous === undefined) {
|
||||
state.current[ACTOR_ID] = newXP;
|
||||
return;
|
||||
}
|
||||
if (Object.is(previous, newXP)) return;
|
||||
|
||||
const diff = newXP - previous;
|
||||
const diffText = diff >= 0 ? `+${diff}` : `${diff}`;
|
||||
const user = game.users.get(userId);
|
||||
const source = consumeDamageSource(actor.id) ?? inferManualSource(diff);
|
||||
|
||||
state.current[ACTOR_ID] = newXP;
|
||||
|
||||
appendHistoryRow(actor, {
|
||||
value: newXP,
|
||||
diffText,
|
||||
user,
|
||||
source,
|
||||
}).catch((err) => console.error("XP History | Failed to append entry", err));
|
||||
});
|
||||
|
||||
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
||||
ui.notifications.info(`XP history tracking active for ${actorName}.`);
|
||||
|
||||
async function appendHistoryRow(actor, entry) {
|
||||
const existing = (await actor.getFlag(FLAG_SCOPE, FLAG_KEY)) ?? [];
|
||||
const newEntry = {
|
||||
timestamp: Date.now(),
|
||||
value: entry.value,
|
||||
diff: entry.diffText,
|
||||
user: entry.user?.name ?? "System",
|
||||
source: entry.source ?? "",
|
||||
};
|
||||
existing.unshift(newEntry);
|
||||
if (existing.length > MAX_ROWS) existing.splice(MAX_ROWS);
|
||||
|
||||
const notes = actor.system.details?.notes?.value ?? "";
|
||||
const block = renderHistoryBlock(existing);
|
||||
const updatedNotes = injectHistoryBlock(notes, block);
|
||||
|
||||
await actor.update(
|
||||
{
|
||||
[`flags.${FLAG_SCOPE}.${FLAG_KEY}`]: existing,
|
||||
"system.details.notes.value": updatedNotes,
|
||||
},
|
||||
{ [UPDATE_CONTEXT_FLAG]: true }
|
||||
);
|
||||
}
|
||||
|
||||
function renderHistoryBlock(entries) {
|
||||
const rows = entries
|
||||
.map(
|
||||
(e) => `
|
||||
<tr data-ts="${e.timestamp}">
|
||||
<td>${new Date(e.timestamp).toLocaleString()}</td>
|
||||
<td>${e.value}</td>
|
||||
<td>${e.diff}</td>
|
||||
<td>${e.user ?? ""}</td>
|
||||
<td>${e.source ?? ""}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const blockId = `${BLOCK_NAME}-${ACTOR_ID}`;
|
||||
return `
|
||||
${BLOCK_START}
|
||||
<section data-history-block="${BLOCK_NAME}" data-history-id="${blockId}">
|
||||
<details open>
|
||||
<summary>===XP History=== (click to collapse)</summary>
|
||||
<div class="history-controls">
|
||||
<label>From <input type="date" data-filter="from"></label>
|
||||
<label>To <input type="date" data-filter="to"></label>
|
||||
</div>
|
||||
<table class="xp-history" data-xp-history="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>XP</th>
|
||||
<th>Δ</th>
|
||||
<th>User</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
<script type="text/javascript">
|
||||
(function(){
|
||||
const root = document.currentScript.closest('section[data-history-block="${BLOCK_NAME}"]');
|
||||
if (!root) return;
|
||||
const rows = Array.from(root.querySelectorAll("tbody tr"));
|
||||
const fromInput = root.querySelector('input[data-filter="from"]');
|
||||
const toInput = root.querySelector('input[data-filter="to"]');
|
||||
function applyFilters(){
|
||||
const from = fromInput?.value ? Date.parse(fromInput.value) : null;
|
||||
const to = toInput?.value ? Date.parse(toInput.value) + 86399999 : null;
|
||||
rows.forEach((row) => {
|
||||
const ts = Number(row.dataset.ts);
|
||||
const visible = (!from || ts >= from) && (!to || ts <= to);
|
||||
row.style.display = visible ? "" : "none";
|
||||
});
|
||||
}
|
||||
fromInput?.addEventListener("input", applyFilters);
|
||||
toInput?.addEventListener("input", applyFilters);
|
||||
applyFilters();
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
${BLOCK_END}`.trim();
|
||||
}
|
||||
|
||||
function injectHistoryBlock(notes, block) {
|
||||
const blockPattern = new RegExp(`${escapeRegExp(BLOCK_START)}[\\s\\S]*?${escapeRegExp(BLOCK_END)}`, "g");
|
||||
let cleaned = notes.replace(blockPattern, "");
|
||||
if (cleaned === notes) {
|
||||
const sectionPattern = new RegExp(`<section[^>]*data-history-block="${BLOCK_NAME}"[^>]*>[\\s\\S]*?<\\/section>`, "g");
|
||||
cleaned = notes.replace(sectionPattern, "");
|
||||
}
|
||||
cleaned = cleaned.trim();
|
||||
const separator = cleaned.length && !cleaned.endsWith("\n") ? "\n\n" : "";
|
||||
return `${cleaned}${separator}${block}`;
|
||||
}
|
||||
|
||||
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 increase" : "Manual decrease";
|
||||
}
|
||||
|
||||
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("XP History | 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 ? "Decrease" : "Increase";
|
||||
|
||||
if (options.isCritical) label += " (Critical)";
|
||||
if (options.asNonlethal) label += " [Nonlethal]";
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
function primeSnapshot(actor) {
|
||||
const xp = readXpValue(actor);
|
||||
if (xp === null) return;
|
||||
state.current[ACTOR_ID] = xp;
|
||||
}
|
||||
|
||||
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 readXpValue(actor) {
|
||||
if (!actor) return null;
|
||||
const value = foundry.utils.getProperty(actor, XP_PATH) ?? foundry.utils.getProperty(actor.system ?? {}, XP_PATH.replace(/^system\./, ""));
|
||||
if (value === undefined) return null;
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : 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("XP History | 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;
|
||||
}
|
||||
|
||||
function escapeRegExp(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Remove the HP history button hooks added by macro_enable-history-dialog.js.
|
||||
*/
|
||||
|
||||
const SHEET_EVENTS = [
|
||||
"renderActorSheetPFCharacter",
|
||||
"renderActorSheetPFNPC",
|
||||
"renderActorSheetPFNPCLoot",
|
||||
"renderActorSheetPFNPCLite",
|
||||
"renderActorSheetPFTrap",
|
||||
"renderActorSheetPFVehicle",
|
||||
"renderActorSheetPFHaunt",
|
||||
"renderActorSheetPFBasic",
|
||||
];
|
||||
|
||||
const hooks = game.pf1?.historyDialog?.renderHooks ?? [];
|
||||
if (!hooks.length) {
|
||||
return ui.notifications.info("History dialog hooks are not active.");
|
||||
}
|
||||
|
||||
hooks.forEach((hook, idx) => {
|
||||
if (hook == null) return;
|
||||
const isObject = typeof hook === "object";
|
||||
const event = isObject ? hook.event : SHEET_EVENTS[idx];
|
||||
const id = isObject ? hook.id : hook;
|
||||
if (event && id !== undefined) Hooks.off(event, id);
|
||||
});
|
||||
|
||||
delete game.pf1.historyDialog.renderHooks;
|
||||
if (game.pf1?.historyDialog?.observers instanceof Map) {
|
||||
for (const observer of game.pf1.historyDialog.observers.values()) {
|
||||
observer?.disconnect();
|
||||
}
|
||||
game.pf1.historyDialog.observers.clear();
|
||||
}
|
||||
game.pf1.historyDialog.observers = new Map();
|
||||
|
||||
ui.notifications.info("History dialog hooks disabled. Reopen actor sheets to remove the button.");
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Remove the History tab hooks added by macro_enable-history-tab.js.
|
||||
*/
|
||||
|
||||
const hooks = game.pf1?.historyTab?.renderHooks ?? (game.pf1?.historyTab?.renderHook ? [{ event: null, id: game.pf1.historyTab.renderHook }] : []);
|
||||
if (!hooks.length) {
|
||||
return ui.notifications.info("History tab hooks are not active.");
|
||||
}
|
||||
|
||||
for (const hook of hooks) {
|
||||
if (!hook) continue;
|
||||
Hooks.off(hook.event ?? "renderActorSheet", hook.id ?? hook);
|
||||
}
|
||||
|
||||
delete game.pf1.historyTab.renderHooks;
|
||||
delete game.pf1.historyTab.renderHook;
|
||||
|
||||
if (game.pf1.historyTab.observers instanceof Map) {
|
||||
for (const observer of game.pf1.historyTab.observers.values()) {
|
||||
observer?.disconnect();
|
||||
}
|
||||
delete game.pf1.historyTab.observers;
|
||||
}
|
||||
|
||||
ui.notifications.info("History tab hooks disabled. Close and reopen actor sheets to remove the tab.");
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* Adds small log buttons to PF1 actor sheets (HP, XP, Currency headers).
|
||||
* Buttons open a modal showing HP / XP / Currency logs stored in world flags.
|
||||
*/
|
||||
|
||||
const HISTORY_BUTTON_CLASS = "pf1-history-btn";
|
||||
const BUTTON_TITLE = "Open Log";
|
||||
const BUTTON_ICON = '<i class="fas fa-clock-rotate-left"></i>';
|
||||
const BUTTON_STYLE =
|
||||
"position:absolute;right:6px;top:4px;border:none;background:transparent;padding:0;width:18px;height:18px;display:flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;";
|
||||
|
||||
const LOG_TARGETS = [
|
||||
{ key: "hp", tab: "hp", finder: findHpContainer },
|
||||
{ key: "xp", tab: "xp", finder: findXpContainer },
|
||||
{ key: "currency", tab: "currency", finder: findCurrencyContainer },
|
||||
];
|
||||
|
||||
const SHEET_EVENTS = [
|
||||
"renderActorSheetPFCharacter",
|
||||
"renderActorSheetPFNPC",
|
||||
"renderActorSheetPFNPCLoot",
|
||||
"renderActorSheetPFNPCLite",
|
||||
"renderActorSheetPFTrap",
|
||||
"renderActorSheetPFVehicle",
|
||||
"renderActorSheetPFHaunt",
|
||||
"renderActorSheetPFBasic",
|
||||
];
|
||||
|
||||
game.pf1 ??= {};
|
||||
game.pf1.historyDialog ??= {};
|
||||
game.pf1.historyDialog.renderHooks ??= [];
|
||||
game.pf1.historyDialog.observers ??= new Map();
|
||||
|
||||
if (game.pf1.historyDialog.renderHooks.length) {
|
||||
return ui.notifications.info("Log buttons already active.");
|
||||
}
|
||||
|
||||
game.pf1.historyDialog.renderHooks = SHEET_EVENTS.map((event) => {
|
||||
const id = Hooks.on(event, (sheet) => {
|
||||
const delays = [0, 100, 250];
|
||||
delays.forEach((delay) =>
|
||||
setTimeout(() => {
|
||||
attachButtons(sheet);
|
||||
}, delay)
|
||||
);
|
||||
});
|
||||
return { event, id };
|
||||
});
|
||||
|
||||
ui.notifications.info("PF1 log buttons enabled. Check the HP/XP/Currency headers.");
|
||||
|
||||
function attachButtons(sheet) {
|
||||
for (const targetCfg of LOG_TARGETS) {
|
||||
const container = targetCfg.finder(sheet.element);
|
||||
if (!container?.length) continue;
|
||||
addButton(container, sheet, targetCfg.tab, `${sheet.id}-${targetCfg.key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function addButton(container, sheet, tab, observerKey) {
|
||||
if (!container.length) return;
|
||||
if (container.find(`.${HISTORY_BUTTON_CLASS}[data-log-tab="${tab}"]`).length) return;
|
||||
|
||||
if (container.css("position") === "static") container.css("position", "relative");
|
||||
|
||||
const button = $(`
|
||||
<button type="button" class="${HISTORY_BUTTON_CLASS}" data-log-tab="${tab}"
|
||||
title="${BUTTON_TITLE}" style="${BUTTON_STYLE}">
|
||||
${BUTTON_ICON}
|
||||
</button>
|
||||
`);
|
||||
button.on("click", () => openHistoryDialog(sheet.actor, tab));
|
||||
container.append(button);
|
||||
|
||||
observeContainer(container, sheet, tab, observerKey);
|
||||
}
|
||||
|
||||
function observeContainer(container, sheet, tab, key) {
|
||||
if (game.pf1.historyDialog.observers.has(key)) return;
|
||||
|
||||
const observer = new MutationObserver(() => addButton(container, sheet, tab, key));
|
||||
observer.observe(container[0], { childList: true });
|
||||
game.pf1.historyDialog.observers.set(key, observer);
|
||||
|
||||
const cleanup = () => {
|
||||
observer.disconnect();
|
||||
game.pf1.historyDialog.observers.delete(key);
|
||||
sheet.element.off("remove", cleanup);
|
||||
};
|
||||
sheet.element.on("remove", cleanup);
|
||||
}
|
||||
|
||||
function findHpContainer(root) {
|
||||
const header = root
|
||||
.find("h3, label")
|
||||
.filter((_, el) => el.textContent.trim() === "Hit Points")
|
||||
.first();
|
||||
if (!header.length) return null;
|
||||
return header.parent().length ? header.parent() : header.closest(".attribute.hitpoints").first();
|
||||
}
|
||||
|
||||
function findXpContainer(root) {
|
||||
const box = root.find(".info-box.experience").first();
|
||||
if (box.length) return box;
|
||||
const header = root
|
||||
.find("h5, label")
|
||||
.filter((_, el) => el.textContent.trim().toLowerCase() === "experience")
|
||||
.first();
|
||||
return header.length ? header.parent() : null;
|
||||
}
|
||||
|
||||
function findCurrencyContainer(root) {
|
||||
const header = root
|
||||
.find("h3, label")
|
||||
.filter((_, el) => el.textContent.trim().toLowerCase() === "currency")
|
||||
.first();
|
||||
if (header.length) return header.parent().length ? header.parent() : header;
|
||||
return root.find(".currency").first();
|
||||
}
|
||||
|
||||
function openHistoryDialog(actor, initialTab = "hp") {
|
||||
if (!actor) return;
|
||||
const content = buildHistoryContent(actor);
|
||||
const dlg = new Dialog(
|
||||
{
|
||||
title: `${actor.name}: Log`,
|
||||
content,
|
||||
buttons: { close: { label: "Close" } },
|
||||
render: (html) => setupTabs(html, initialTab),
|
||||
},
|
||||
{
|
||||
width: 720,
|
||||
classes: ["pf1-history-dialog"],
|
||||
}
|
||||
);
|
||||
dlg.render(true);
|
||||
}
|
||||
|
||||
function buildHistoryContent(actor) {
|
||||
const configs = [
|
||||
{
|
||||
id: "hp",
|
||||
label: "HP",
|
||||
flag: "pf1HpHistory",
|
||||
columns: [
|
||||
{ label: "Timestamp", render: (e) => formatDate(e.timestamp) },
|
||||
{ label: "HP", render: (e) => e.hp },
|
||||
{ label: "Δ", render: (e) => e.diff },
|
||||
{ label: "User", render: (e) => e.user ?? "" },
|
||||
{ label: "Source", render: (e) => e.source ?? "" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "xp",
|
||||
label: "XP",
|
||||
flag: "pf1XpHistory",
|
||||
columns: [
|
||||
{ label: "Timestamp", render: (e) => formatDate(e.timestamp) },
|
||||
{ label: "XP", render: (e) => e.value },
|
||||
{ label: "Δ", render: (e) => e.diff },
|
||||
{ label: "User", render: (e) => e.user ?? "" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "currency",
|
||||
label: "Currency",
|
||||
flag: "pf1CurrencyHistory",
|
||||
columns: [
|
||||
{ label: "Timestamp", render: (e) => formatDate(e.timestamp) },
|
||||
{ label: "Totals", render: (e) => e.value },
|
||||
{ label: "Δ", render: (e) => e.diff },
|
||||
{ label: "User", render: (e) => e.user ?? "" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const tabs = configs
|
||||
.map((cfg) => `<a class="history-tab" data-history-tab="${cfg.id}">${cfg.label}</a>`)
|
||||
.join("");
|
||||
|
||||
const panels = configs
|
||||
.map((cfg) => {
|
||||
const entries = actor.getFlag("world", cfg.flag) ?? [];
|
||||
const table = renderHistoryTable(entries, cfg.columns, cfg.id);
|
||||
return `<section class="history-panel" data-history-panel="${cfg.id}">${table}</section>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<section class="history-dialog-root" data-history-root="${actor.id}">
|
||||
<style>
|
||||
.history-dialog-tabs { display:flex; gap:6px; margin-bottom:8px; }
|
||||
.history-tab { flex:1; text-align:center; padding:4px 0; border:1px solid #b5b3a4; border-radius:3px; cursor:pointer; background:#ddd; }
|
||||
.history-tab.active { background:#fff; font-weight:bold; }
|
||||
</style>
|
||||
<nav class="history-dialog-tabs">${tabs}</nav>
|
||||
<div class="history-dialog-panels">${panels}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function setupTabs(html, initialTab) {
|
||||
const buttons = Array.from(html[0].querySelectorAll(".history-tab"));
|
||||
const panels = Array.from(html[0].querySelectorAll(".history-panel"));
|
||||
const activate = (target) => {
|
||||
buttons.forEach((btn) => btn.classList.toggle("active", btn.dataset.historyTab === target));
|
||||
panels.forEach((panel) => (panel.style.display = panel.dataset.historyPanel === target ? "block" : "none"));
|
||||
};
|
||||
buttons.forEach((btn) => btn.addEventListener("click", () => activate(btn.dataset.historyTab)));
|
||||
activate(initialTab);
|
||||
}
|
||||
|
||||
function renderHistoryTable(entries, columns, id) {
|
||||
if (!entries.length) {
|
||||
return `<p class="history-empty">No ${id.toUpperCase()} history recorded.</p>`;
|
||||
}
|
||||
|
||||
const rows = entries
|
||||
.map(
|
||||
(entry) => `
|
||||
<tr>
|
||||
${columns.map((col) => `<td>${col.render(entry) ?? ""}</td>`).join("")}
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<table class="history-table history-${id}">
|
||||
<thead>
|
||||
<tr>${columns.map((col) => `<th>${col.label}</th>`).join("")}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function formatDate(ts) {
|
||||
return ts ? new Date(ts).toLocaleString() : "";
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* Add a History tab to PF1 actor sheets with HP/XP/Currency subtabs.
|
||||
* Requires the corresponding flag-tracking macros to populate data.
|
||||
*/
|
||||
|
||||
const TAB_ID = "pf1-history";
|
||||
const TAB_LABEL = "History";
|
||||
|
||||
if (!game.system.id.includes("pf1")) {
|
||||
ui.notifications.warn("This macro is intended for the PF1 system.");
|
||||
return;
|
||||
}
|
||||
|
||||
game.pf1 ??= {};
|
||||
game.pf1.historyTab ??= {};
|
||||
|
||||
const PF1_SHEET_HOOKS = [
|
||||
"renderActorSheetPFCharacter",
|
||||
"renderActorSheetPFNPC",
|
||||
"renderActorSheetPFNPCLoot",
|
||||
"renderActorSheetPFNPCLite",
|
||||
"renderActorSheetPFTrap",
|
||||
"renderActorSheetPFVehicle",
|
||||
"renderActorSheetPFHaunt",
|
||||
"renderActorSheetPFBasic",
|
||||
];
|
||||
|
||||
game.pf1.historyTab.renderHooks ??= [];
|
||||
if (game.pf1.historyTab.renderHooks.length) {
|
||||
return ui.notifications.info("History tab hooks already active.");
|
||||
}
|
||||
|
||||
for (const hook of PF1_SHEET_HOOKS) {
|
||||
const id = Hooks.on(hook, (sheet, html) => {
|
||||
const delays = [0, 50, 200];
|
||||
delays.forEach((delay) =>
|
||||
setTimeout(() => {
|
||||
try {
|
||||
injectHistoryTab(sheet);
|
||||
} catch (err) {
|
||||
console.error("PF1 History Tab | Failed to render history tab", err);
|
||||
}
|
||||
}, delay)
|
||||
);
|
||||
});
|
||||
game.pf1.historyTab.renderHooks.push({ event: hook, id });
|
||||
}
|
||||
|
||||
ui.notifications.info("PF1 History tab enabled. Reopen actor sheets to see the new tab.");
|
||||
|
||||
function injectHistoryTab(sheet) {
|
||||
if (!sheet?.element?.length) return;
|
||||
console.log("PF1 History Tab | Injecting for", sheet.actor?.name, sheet.id);
|
||||
const actor = sheet.actor;
|
||||
const nav = sheet.element.find('.sheet-navigation.tabs[data-group="primary"]').first();
|
||||
const body = sheet.element.find(".sheet-body").first();
|
||||
if (!nav.length || !body.length) return;
|
||||
|
||||
ensureHistoryTab(nav);
|
||||
|
||||
const existing = body.find(`.tab[data-tab="${TAB_ID}"]`).first();
|
||||
const content = buildHistoryContent(actor);
|
||||
if (existing.length) existing.html(content);
|
||||
else body.append(`<div class="tab history-tab" data-tab="${TAB_ID}" data-group="primary">${content}</div>`);
|
||||
|
||||
sheet._tabs?.forEach((tabs) => tabs.bind(sheet.element[0]));
|
||||
|
||||
setupObserver(sheet, nav);
|
||||
}
|
||||
|
||||
function ensureHistoryTab(nav) {
|
||||
if (nav.find(`[data-tab="${TAB_ID}"]`).length) return;
|
||||
nav.append(`<a class="item" data-group="primary" data-tab="${TAB_ID}">${TAB_LABEL}</a>`);
|
||||
}
|
||||
|
||||
function setupObserver(sheet, nav) {
|
||||
game.pf1.historyTab.observers ??= new Map();
|
||||
const key = sheet.id;
|
||||
if (game.pf1.historyTab.observers.has(key)) return;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const currentNav = sheet.element.find('.sheet-navigation.tabs[data-group="primary"]').first();
|
||||
if (!currentNav.length) return;
|
||||
ensureHistoryTab(currentNav);
|
||||
});
|
||||
|
||||
observer.observe(sheet.element[0], { childList: true, subtree: true });
|
||||
game.pf1.historyTab.observers.set(key, observer);
|
||||
|
||||
const closeHandler = () => {
|
||||
observer.disconnect();
|
||||
game.pf1.historyTab.observers.delete(key);
|
||||
sheet.element.off("remove", closeHandler);
|
||||
};
|
||||
sheet.element.on("remove", closeHandler);
|
||||
}
|
||||
|
||||
function buildHistoryContent(actor) {
|
||||
const configs = [
|
||||
{
|
||||
id: "hp",
|
||||
label: "HP",
|
||||
flag: "pf1HpHistory",
|
||||
columns: [
|
||||
{ label: "Timestamp", render: (e) => formatDate(e.timestamp) },
|
||||
{ label: "HP", render: (e) => e.hp },
|
||||
{ label: "Δ", render: (e) => e.diff },
|
||||
{ label: "User", render: (e) => e.user ?? "" },
|
||||
{ label: "Source", render: (e) => e.source ?? "" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "xp",
|
||||
label: "XP",
|
||||
flag: "pf1XpHistory",
|
||||
columns: [
|
||||
{ label: "Timestamp", render: (e) => formatDate(e.timestamp) },
|
||||
{ label: "XP", render: (e) => e.value },
|
||||
{ label: "Δ", render: (e) => e.diff },
|
||||
{ label: "User", render: (e) => e.user ?? "" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "currency",
|
||||
label: "Currency",
|
||||
flag: "pf1CurrencyHistory",
|
||||
columns: [
|
||||
{ label: "Timestamp", render: (e) => formatDate(e.timestamp) },
|
||||
{ label: "Totals", render: (e) => e.value },
|
||||
{ label: "Δ", render: (e) => e.diff },
|
||||
{ label: "User", render: (e) => e.user ?? "" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const nav = configs
|
||||
.map((cfg, idx) => `<a class="history-subtab ${idx === 0 ? "active" : ""}" data-history-subtab="${cfg.id}">${cfg.label}</a>`)
|
||||
.join("");
|
||||
|
||||
const panels = configs
|
||||
.map((cfg, idx) => {
|
||||
const entries = actor.getFlag("world", cfg.flag) ?? [];
|
||||
const table = renderHistoryTable(entries, cfg.columns, cfg.id);
|
||||
return `<section class="history-panel" data-history-panel="${cfg.id}" style="display:${idx === 0 ? "block" : "none"}">${table}</section>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<section class="pf1-history-root" data-history-block="history-tab">
|
||||
<nav class="history-subnav">
|
||||
${nav}
|
||||
</nav>
|
||||
<div class="history-panels">
|
||||
${panels}
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
(function(){
|
||||
const root = document.currentScript.closest('[data-history-block="history-tab"]');
|
||||
if (!root) return;
|
||||
const tabs = Array.from(root.querySelectorAll('[data-history-subtab]'));
|
||||
const panels = Array.from(root.querySelectorAll('[data-history-panel]'));
|
||||
tabs.forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const target = btn.dataset.historySubtab;
|
||||
tabs.forEach((n) => n.classList.toggle('active', n === btn));
|
||||
panels.forEach((panel) => (panel.style.display = panel.dataset.historyPanel === target ? 'block' : 'none'));
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderHistoryTable(entries, columns, id) {
|
||||
if (!entries.length) return `<p class="history-empty">No ${id.toUpperCase()} history recorded.</p>`;
|
||||
|
||||
const rows = entries
|
||||
.map(
|
||||
(entry) => `
|
||||
<tr data-ts="${entry.timestamp}">
|
||||
${columns.map((col) => `<td>${col.render(entry) ?? ""}</td>`).join("")}
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="history-filters">
|
||||
<label>From <input type="date" data-filter="from-${id}"></label>
|
||||
<label>To <input type="date" data-filter="to-${id}"></label>
|
||||
</div>
|
||||
<table class="history-table history-${id}">
|
||||
<thead>
|
||||
<tr>${columns.map((col) => `<th>${col.label}</th>`).join("")}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
<script type="text/javascript">
|
||||
(function(){
|
||||
const root = document.currentScript.closest('.history-panel[data-history-panel="${id}"]');
|
||||
if (!root) return;
|
||||
const rows = Array.from(root.querySelectorAll("tbody tr"));
|
||||
const fromInput = root.querySelector('input[data-filter="from-${id}"]');
|
||||
const toInput = root.querySelector('input[data-filter="to-${id}"]');
|
||||
function applyFilters(){
|
||||
const from = fromInput?.value ? Date.parse(fromInput.value) : null;
|
||||
const to = toInput?.value ? Date.parse(toInput.value) + 86399999 : null;
|
||||
rows.forEach((row) => {
|
||||
const ts = Number(row.dataset.ts);
|
||||
const visible = (!from || ts >= from) && (!to || ts <= to);
|
||||
row.style.display = visible ? "" : "none";
|
||||
});
|
||||
}
|
||||
fromInput?.addEventListener("input", applyFilters);
|
||||
toInput?.addEventListener("input", applyFilters);
|
||||
applyFilters();
|
||||
})();
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatDate(ts) {
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Stop currency flag history tracking (tab-based version).
|
||||
*/
|
||||
|
||||
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
|
||||
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here";
|
||||
|
||||
if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") {
|
||||
return ui.notifications.warn("Set ACTOR_ID before running the macro.");
|
||||
}
|
||||
|
||||
const state = game.pf1?.currencyHistoryFlags;
|
||||
if (!state) return ui.notifications.info("Currency flag history tracking is not active.");
|
||||
|
||||
const logKey = `pf1-currency-history-flags-${ACTOR_ID}`;
|
||||
const handler = state.hooks?.[logKey];
|
||||
|
||||
if (!handler) {
|
||||
return ui.notifications.info(`No currency flag history hook found for ${TARGET_ACTOR?.name ?? ACTOR_ID}.`);
|
||||
}
|
||||
|
||||
Hooks.off("updateActor", handler);
|
||||
delete state.hooks[logKey];
|
||||
if (state.current) delete state.current[ACTOR_ID];
|
||||
|
||||
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
||||
ui.notifications.info(`Currency flag history tracking disabled for ${actorName}.`);
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Stop currency history tracking for the configured actor.
|
||||
*/
|
||||
|
||||
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
|
||||
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here";
|
||||
|
||||
if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") {
|
||||
return ui.notifications.warn("Set ACTOR_ID before running the macro.");
|
||||
}
|
||||
|
||||
const state = game.pf1?.currencyHistory;
|
||||
if (!state) return ui.notifications.info("Currency history tracking is not active.");
|
||||
|
||||
const logKey = `pf1-currency-history-${ACTOR_ID}`;
|
||||
const handler = state.hooks?.[logKey];
|
||||
|
||||
if (!handler) {
|
||||
return ui.notifications.info(`No currency history hook found for ${TARGET_ACTOR?.name ?? ACTOR_ID}.`);
|
||||
}
|
||||
|
||||
Hooks.off("updateActor", handler);
|
||||
delete state.hooks[logKey];
|
||||
state.sources?.delete(ACTOR_ID);
|
||||
if (state.current) delete state.current[ACTOR_ID];
|
||||
|
||||
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
||||
ui.notifications.info(`Currency history tracking disabled for ${actorName}.`);
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Stop HP flag history tracking (tab-based version).
|
||||
*/
|
||||
|
||||
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
|
||||
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here";
|
||||
|
||||
if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") {
|
||||
return ui.notifications.warn("Set ACTOR_ID before running the macro.");
|
||||
}
|
||||
|
||||
const state = game.pf1?.hpHistoryFlags;
|
||||
if (!state) return ui.notifications.info("HP flag history tracking is not active.");
|
||||
|
||||
const logKey = `pf1-hp-history-flags-${ACTOR_ID}`;
|
||||
const handler = state.hooks?.[logKey];
|
||||
|
||||
if (!handler) {
|
||||
return ui.notifications.info(`No HP flag history hook found for ${TARGET_ACTOR?.name ?? ACTOR_ID}.`);
|
||||
}
|
||||
|
||||
Hooks.off("updateActor", handler);
|
||||
delete state.hooks[logKey];
|
||||
state.sources?.delete(ACTOR_ID);
|
||||
if (state.current) delete state.current[ACTOR_ID];
|
||||
|
||||
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
||||
ui.notifications.info(`HP flag history tracking disabled for ${actorName}.`);
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Deactivate the HP history tracker and stop writing to the Notes table.
|
||||
*/
|
||||
|
||||
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
|
||||
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here";
|
||||
|
||||
if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") {
|
||||
return ui.notifications.warn("Set ACTOR_ID before running the macro.");
|
||||
}
|
||||
|
||||
game.pf1 ??= {};
|
||||
const state = game.pf1.hpHistory;
|
||||
|
||||
if (!state) {
|
||||
return ui.notifications.info("HP history tracking is not active.");
|
||||
}
|
||||
|
||||
const logKey = `pf1-hp-history-${ACTOR_ID}`;
|
||||
const handler = state.hooks?.[logKey];
|
||||
|
||||
if (!handler) {
|
||||
return ui.notifications.info(`No HP history hook found for ${TARGET_ACTOR?.name ?? ACTOR_ID}.`);
|
||||
}
|
||||
|
||||
Hooks.off("updateActor", handler);
|
||||
delete state.hooks[logKey];
|
||||
state.sources?.delete(ACTOR_ID);
|
||||
if (state.current) delete state.current[ACTOR_ID];
|
||||
|
||||
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
||||
ui.notifications.info(`HP history tracking disabled for ${actorName}.`);
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Deactivate HP tracking for the configured actor.
|
||||
* Use after running macro_activate-hp-tracking.js to stop logging.
|
||||
*/
|
||||
|
||||
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
|
||||
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here";
|
||||
|
||||
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 ??= {};
|
||||
|
||||
const logKey = `pf1-hp-log-${ACTOR_ID}`;
|
||||
const handler = game.pf1.hpLogger[logKey];
|
||||
|
||||
if (!handler) {
|
||||
return ui.notifications.info(`No HP tracking hook found for ${TARGET_ACTOR?.name ?? ACTOR_ID}.`);
|
||||
}
|
||||
|
||||
Hooks.off("updateActor", handler);
|
||||
delete game.pf1.hpLogger[logKey];
|
||||
|
||||
if (game.pf1.hpLogger.sources instanceof Map) {
|
||||
game.pf1.hpLogger.sources.delete(ACTOR_ID);
|
||||
}
|
||||
if (game.pf1.hpLogger.current) {
|
||||
delete game.pf1.hpLogger.current[ACTOR_ID];
|
||||
}
|
||||
|
||||
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
||||
ui.notifications.info(`HP tracking disabled for ${actorName}.`);
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Stop XP flag history tracking (tab-based version).
|
||||
*/
|
||||
|
||||
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
|
||||
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here";
|
||||
|
||||
if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") {
|
||||
return ui.notifications.warn("Set ACTOR_ID before running the macro.");
|
||||
}
|
||||
|
||||
const state = game.pf1?.xpHistoryFlags;
|
||||
if (!state) return ui.notifications.info("XP flag history tracking is not active.");
|
||||
|
||||
const logKey = `pf1-xp-history-flags-${ACTOR_ID}`;
|
||||
const handler = state.hooks?.[logKey];
|
||||
|
||||
if (!handler) {
|
||||
return ui.notifications.info(`No XP flag history hook found for ${TARGET_ACTOR?.name ?? ACTOR_ID}.`);
|
||||
}
|
||||
|
||||
Hooks.off("updateActor", handler);
|
||||
delete state.hooks[logKey];
|
||||
if (state.current) delete state.current[ACTOR_ID];
|
||||
|
||||
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
||||
ui.notifications.info(`XP flag history tracking disabled for ${actorName}.`);
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Stop XP history tracking for the configured actor.
|
||||
*/
|
||||
|
||||
const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null;
|
||||
const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here";
|
||||
|
||||
if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") {
|
||||
return ui.notifications.warn("Set ACTOR_ID before running the macro.");
|
||||
}
|
||||
|
||||
const state = game.pf1?.xpHistory;
|
||||
if (!state) return ui.notifications.info("XP history tracking is not active.");
|
||||
|
||||
const logKey = `pf1-xp-history-${ACTOR_ID}`;
|
||||
const handler = state.hooks?.[logKey];
|
||||
|
||||
if (!handler) {
|
||||
return ui.notifications.info(`No XP history hook found for ${TARGET_ACTOR?.name ?? ACTOR_ID}.`);
|
||||
}
|
||||
|
||||
Hooks.off("updateActor", handler);
|
||||
delete state.hooks[logKey];
|
||||
state.sources?.delete(ACTOR_ID);
|
||||
if (state.current) delete state.current[ACTOR_ID];
|
||||
|
||||
const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID;
|
||||
ui.notifications.info(`XP history tracking disabled for ${actorName}.`);
|
||||
@@ -329,8 +329,9 @@ async function initializeModule() {
|
||||
try {
|
||||
const message = options.message;
|
||||
if (message) {
|
||||
const storedValue = computeDamageValue(value, options);
|
||||
const source = buildSourceLabel(value, options);
|
||||
console.log("[GowlersTracking] Storing message for matching: source=", source, "value=", value);
|
||||
console.log("[GowlersTracking] Storing message for matching: source=", source, "value=", storedValue);
|
||||
|
||||
// Extract damage details for breakdown information
|
||||
const damageDetails = extractDamageDetails(value, options);
|
||||
@@ -339,7 +340,7 @@ async function initializeModule() {
|
||||
ledgerState.recentMessages.push({
|
||||
message: message,
|
||||
source: source,
|
||||
value: value, // Keep sign! Negative = damage, Positive = healing
|
||||
value: storedValue, // Keep sign! Negative = damage, Positive = healing
|
||||
damageDetails: damageDetails,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -645,6 +646,18 @@ function registerSceneControls(forceRefresh = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function computeDamageValue(value, options = {}) {
|
||||
const base = Number(value) || 0;
|
||||
if (!Array.isArray(options.instances) || !options.instances.length) return base;
|
||||
const sum = options.instances.reduce((total, inst) => {
|
||||
const parts = [inst.total, inst.value, inst.amount].filter((v) => Number.isFinite(v));
|
||||
return total + (parts.length ? parts[0] : 0);
|
||||
}, 0);
|
||||
if (!sum) return base;
|
||||
const sign = base < 0 ? -1 : 1;
|
||||
return sign * Math.abs(sum);
|
||||
}
|
||||
|
||||
function openDamageMeterOverlay() {
|
||||
try {
|
||||
if (ledgerState.damageOverlay?.rendered) {
|
||||
|
||||
Reference in New Issue
Block a user