This commit is contained in:
centron\schwoerer
2025-11-14 10:30:00 +01:00
parent 5669aa75ca
commit 30aa03c6db
8 changed files with 1042 additions and 0 deletions

View File

@@ -0,0 +1,300 @@
/**
* 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, "\\$&");
}

View File

@@ -0,0 +1,369 @@
/**
* 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, "\\$&");
}

View File

@@ -0,0 +1,285 @@
/**
* 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, "\\$&");
}

View File

@@ -0,0 +1,28 @@
/**
* 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}.`);

View File

@@ -0,0 +1,32 @@
/**
* 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}.`);

View File

@@ -0,0 +1,28 @@
/**
* 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}.`);