history
This commit is contained in:
300
src/macro_activate-currency-history.js
Normal file
300
src/macro_activate-currency-history.js
Normal 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, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user