This commit is contained in:
centron\schwoerer
2025-11-18 09:46:10 +01:00
parent f054a31b20
commit aec929b433
4 changed files with 682 additions and 32 deletions

View File

@@ -27,7 +27,9 @@ const SHEET_EVENTS = [
];
game.pf1 ??= {};
game.pf1.historyDialog ??= { observers: new Map(), renderHooks: [] };
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.");
@@ -51,11 +53,11 @@ function attachButtons(sheet) {
for (const targetCfg of LOG_TARGETS) {
const container = targetCfg.finder(sheet.element);
if (!container?.length) continue;
addButton(container, sheet.actor, targetCfg.tab, `${sheet.id}-${targetCfg.key}`);
addButton(container, sheet, targetCfg.tab, `${sheet.id}-${targetCfg.key}`);
}
}
function addButton(container, actor, tab, observerKey) {
function addButton(container, sheet, tab, observerKey) {
if (!container.length) return;
if (container.find(`.${HISTORY_BUTTON_CLASS}[data-log-tab="${tab}"]`).length) return;
@@ -67,18 +69,25 @@ function addButton(container, actor, tab, observerKey) {
${BUTTON_ICON}
</button>
`);
button.on("click", () => openHistoryDialog(actor, tab));
button.on("click", () => openHistoryDialog(sheet.actor, tab));
container.append(button);
observeContainer(container, actor, tab, observerKey);
observeContainer(container, sheet, tab, observerKey);
}
function observeContainer(container, actor, tab, key) {
function observeContainer(container, sheet, tab, key) {
if (game.pf1.historyDialog.observers.has(key)) return;
const observer = new MutationObserver(() => addButton(container, actor, tab, key));
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) {
@@ -101,31 +110,33 @@ function findXpContainer(root) {
}
function findCurrencyContainer(root) {
const summaryHeader = root
const header = root
.find("h3, label")
.filter((_, el) => el.textContent.trim().toLowerCase() === "currency")
.first();
if (summaryHeader.length) return summaryHeader.parent();
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, initialTab);
new Dialog(
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"],
}
).render(true);
);
dlg.render(true);
}
function buildHistoryContent(actor, initialTab = "hp") {
function buildHistoryContent(actor) {
const configs = [
{
id: "hp",
@@ -164,18 +175,14 @@ function buildHistoryContent(actor, initialTab = "hp") {
];
const tabs = configs
.map(
(cfg) =>
`<a class="history-tab ${cfg.id === initialTab ? "active" : ""}" data-history-tab="${cfg.id}">${cfg.label}</a>`
)
.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);
const display = cfg.id === initialTab ? "block" : "none";
return `<section class="history-panel" data-history-panel="${cfg.id}" style="display:${display}">${table}</section>`;
return `<section class="history-panel" data-history-panel="${cfg.id}">${table}</section>`;
})
.join("");
@@ -188,22 +195,20 @@ function buildHistoryContent(actor, initialTab = "hp") {
</style>
<nav class="history-dialog-tabs">${tabs}</nav>
<div class="history-dialog-panels">${panels}</div>
<script type="text/javascript">
(function(){
const root = document.currentScript.closest('[data-history-root="${actor.id}"]');
if (!root) return;
const buttons = Array.from(root.querySelectorAll('.history-tab'));
const panels = Array.from(root.querySelectorAll('.history-panel'));
function 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)));
})();
</script>
</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>`;

View File

@@ -0,0 +1,15 @@
{
"id": "gowlers-tracking-ledger",
"type": "module",
"title": "Gowler's Tracking Ledger",
"description": "Adds HP/XP/Currency log buttons to PF1 sheets and opens the tracking dialog preloaded with the actor's logs.",
"version": "0.1.0",
"authors": [
{ "name": "Gowler", "url": "https://foundryvtt.com" }
],
"compatibility": {
"minimum": "11",
"verified": "11"
},
"scripts": ["scripts/gowlers-tracking-ledger.js"]
}

View File

@@ -0,0 +1,599 @@
(function () {
const MODULE_ID = "gowlers-tracking-ledger";
const 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;";
let trackingConfig = {};
const LOG_TARGETS = [
{ key: "hp", tab: "hp", finder: findHpContainer },
{ key: "xp", tab: "xp", finder: findXpContainer },
{ key: "currency", tab: "currency", finder: findCurrencyContainer },
];
const TRACKERS = {
hp: {
key: "hp",
flag: "pf1HpHistory",
updateFlag: "hpHistoryFlags",
maxRows: 50,
state: { current: new Map(), sources: new Map(), updateHookId: null, applyHooked: false },
readValue: readHpValue,
cloneValue: (value) => value,
equal: (a, b) => Object.is(a, b),
buildEntry(actor, newVal, prevVal, userId) {
const diff = newVal - prevVal;
const diffText = diff >= 0 ? `+${diff}` : `${diff}`;
const user = game.users.get(userId);
const source = consumeDamageSource(actor.id) ?? inferManualSource(diff);
return {
hp: newVal,
diff: diffText,
user: user?.name ?? "System",
source: source ?? "",
};
},
initExtras() {
ensureDamageSourceTracking(this.state);
},
},
xp: {
key: "xp",
flag: "pf1XpHistory",
maxRows: 50,
state: { current: new Map(), updateHookId: null },
readValue: readXpValue,
cloneValue: (value) => value,
equal: (a, b) => Object.is(a, b),
buildEntry(actor, newVal, prevVal, userId) {
const diff = newVal - prevVal;
const diffText = diff >= 0 ? `+${diff}` : `${diff}`;
const user = game.users.get(userId);
return {
value: newVal,
diff: diffText,
user: user?.name ?? "System",
};
},
},
currency: {
key: "currency",
flag: "pf1CurrencyHistory",
maxRows: 50,
state: { current: new Map(), updateHookId: null },
readValue: readCurrency,
cloneValue: (value) => ({ ...value }),
equal: currencyEquals,
buildEntry(actor, newVal, prevVal, userId) {
const diff = diffCurrency(prevVal, newVal);
const user = game.users.get(userId);
return {
value: formatCurrency(newVal),
diff: formatCurrency(diff, true),
user: user?.name ?? "System",
};
},
},
};
Hooks.once("init", () => {
registerSettings();
});
Hooks.once("ready", () => {
registerButtons();
reconfigureTracking();
});
function registerSettings() {
game.settings.register(MODULE_ID, "trackingConfig", {
scope: "world",
config: false,
type: Object,
default: {},
});
game.settings.registerMenu(MODULE_ID, "config", {
name: "Tracking Ledger",
label: "Configure Tracking",
hint: "Choose which actors have HP/XP/Currency logs recorded.",
restricted: true,
type: TrackingLedgerConfig,
});
}
function reconfigureTracking() {
trackingConfig = duplicate(game.settings.get(MODULE_ID, "trackingConfig") ?? {});
for (const tracker of Object.values(TRACKERS)) {
tracker.initExtras?.();
ensureUpdateHook(tracker);
// Remove current values for actors no longer tracked.
for (const actorId of Array.from(tracker.state.current.keys())) {
if (!trackingConfig[actorId]?.[tracker.key]) tracker.state.current.delete(actorId);
}
// Initialize tracked actors.
for (const [actorId, settings] of Object.entries(trackingConfig)) {
if (!settings?.[tracker.key]) continue;
const actor = game.actors.get(actorId);
if (!actor) continue;
const value = tracker.readValue(actor);
if (value === null || value === undefined) continue;
tracker.state.current.set(actorId, tracker.cloneValue ? tracker.cloneValue(value) : value);
}
}
}
function ensureUpdateHook(tracker) {
if (tracker.state.updateHookId) return;
tracker.state.updateHookId = Hooks.on("updateActor", (actor, change, options, userId) => {
if (!trackingConfig[actor.id]?.[tracker.key]) return;
processTrackerChange(tracker, actor, userId);
});
}
async function processTrackerChange(tracker, actor, userId) {
const actorId = actor.id;
const newValue = tracker.readValue(actor);
if (newValue === null || newValue === undefined) return;
const prev = tracker.state.current.get(actorId);
if (prev === undefined) {
tracker.state.current.set(actorId, tracker.cloneValue ? tracker.cloneValue(newValue) : newValue);
return;
}
const isEqual = tracker.equal ? tracker.equal(prev, newValue) : Object.is(prev, newValue);
if (isEqual) return;
tracker.state.current.set(actorId, tracker.cloneValue ? tracker.cloneValue(newValue) : newValue);
const entry = tracker.buildEntry(actor, newValue, prev, userId);
entry.timestamp = Date.now();
await appendEntry(actor, tracker.flag, entry, tracker.maxRows);
}
async function appendEntry(actor, flag, entry, maxRows) {
const existing = (await actor.getFlag("world", flag)) ?? [];
existing.unshift(entry);
if (existing.length > maxRows) existing.splice(maxRows);
await actor.setFlag("world", flag, existing);
}
// ---------------- Settings Form ----------------
class TrackingLedgerConfig extends FormApplication {
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
id: "gowlers-tracking-ledger-config",
title: "Tracking Ledger",
template: `modules/${MODULE_ID}/templates/config.hbs`,
width: 600,
height: "auto",
});
}
getData() {
const config = game.settings.get(MODULE_ID, "trackingConfig") ?? {};
const actors = game.actors.map((actor) => ({
id: actor.id,
name: actor.name,
type: actor.type,
tracking: config[actor.id] ?? {},
}));
return { actors };
}
async _updateObject(event, formData) {
const expanded = expandObject(formData);
const config = {};
const actorsData = expanded.actors ?? {};
for (const [actorId, trackerFlags] of Object.entries(actorsData)) {
const entry = {};
for (const key of Object.keys(TRACKERS)) {
if (trackerFlags[key]) entry[key] = true;
}
if (!foundry.utils.isEmpty(entry)) config[actorId] = entry;
}
await game.settings.set(MODULE_ID, "trackingConfig", config);
reconfigureTracking();
}
}
// --------------- Log Buttons ---------------
function registerButtons() {
const state = ensureButtonState();
if (state.renderHooks.length) return;
const sheetEvents = [
"renderActorSheetPFCharacter",
"renderActorSheetPFNPC",
"renderActorSheetPFNPCLoot",
"renderActorSheetPFNPCLite",
"renderActorSheetPFTrap",
"renderActorSheetPFVehicle",
"renderActorSheetPFHaunt",
"renderActorSheetPFBasic",
];
state.renderHooks = sheetEvents.map((event) => {
const id = Hooks.on(event, (sheet) => {
const delays = [0, 100, 250];
delays.forEach((delay) => setTimeout(() => attachButtons(sheet, state), delay));
});
return { event, id };
});
}
function ensureButtonState() {
game.pf1 ??= {};
game.pf1[`${MODULE_ID}-buttons`] ??= { renderHooks: [], observers: new Map() };
return game.pf1[`${MODULE_ID}-buttons`];
}
function attachButtons(sheet, state) {
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}`, state);
}
}
function addButton(container, sheet, tab, observerKey, state) {
if (!container.length) return;
if (container.find(`.${BUTTON_CLASS}[data-log-tab="${tab}"]`).length) return;
if (container.css("position") === "static") container.css("position", "relative");
const button = $(`
<button type="button" class="${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, state);
}
function observeContainer(container, sheet, tab, key, state) {
if (state.observers.has(key)) return;
const observer = new MutationObserver(() => addButton(container, sheet, tab, key, state));
observer.observe(container[0], { childList: true });
state.observers.set(key, observer);
const cleanup = () => {
observer.disconnect();
state.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().length ? header.parent() : header) : 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 = "hp") {
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() : "";
}
// --------- HP Helpers ----------
function readHpValue(actor) {
if (!actor) return null;
const value = foundry.utils.getProperty(actor, "system.attributes.hp.value");
if (value === undefined) return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function consumeDamageSource(actorId) {
const tracker = TRACKERS.hp;
const entry = tracker.state.sources.get(actorId);
if (!entry) return null;
tracker.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(state) {
if (state.applyHooked) 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, state);
} catch (err) {
console.warn("Tracking Ledger | Failed to record damage source", err);
}
return original.call(this, value, options);
};
state.applyHooked = true;
}
function noteDamageSource(value, options, state) {
const actors = resolveActorTargets(options?.targets);
for (const actor of actors) {
const label = buildSourceLabel(value, options, actor);
if (!label) continue;
state.sources.set(actor.id, { label, ts: Date.now() });
}
}
function buildSourceLabel(value, options, actor) {
const info = options?.message?.flags?.pf1?.identifiedInfo ?? {};
const metadata = options?.message?.flags?.pf1?.metadata ?? {};
const fromChatFlavor = options?.message?.flavor?.trim();
const actorDoc = resolveActorFromMetadata(metadata) ?? actor;
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 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 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("Tracking Ledger | 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;
}
// --------- XP Helpers ----------
function readXpValue(actor) {
if (!actor) return null;
const direct = foundry.utils.getProperty(actor, "system.details.xp.value");
if (direct !== undefined) {
const numeric = Number(direct);
return Number.isFinite(numeric) ? numeric : null;
}
const sys = foundry.utils.getProperty(actor.system ?? {}, "details.xp.value");
if (sys !== undefined) {
const numeric = Number(sys);
return Number.isFinite(numeric) ? numeric : null;
}
return null;
}
// --------- Currency Helpers ----------
const COIN_ORDER = ["pp", "gp", "sp", "cp"];
function readCurrency(actor) {
if (!actor) return null;
const base = foundry.utils.getProperty(actor, "system.currency") ?? 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(" ");
}
// -------- Settings Template Helper --------
function formatDate(ts) {
return ts ? new Date(ts).toLocaleString() : "";
}
// Expose API for debugging if needed.
game.modules.get(MODULE_ID).api = { reconfigureTracking };
})();

View File

@@ -0,0 +1,31 @@
<form class="tracking-ledger-config">
<table class="tracking-ledger-table">
<thead>
<tr>
<th>Actor</th>
<th>HP</th>
<th>XP</th>
<th>Currency</th>
</tr>
</thead>
<tbody>
{{#each actors}}
<tr>
<td>{{name}}</td>
<td>
<input type="checkbox" name="actors.{{id}}.hp" {{#if tracking.hp}}checked{{/if}}>
</td>
<td>
<input type="checkbox" name="actors.{{id}}.xp" {{#if tracking.xp}}checked{{/if}}>
</td>
<td>
<input type="checkbox" name="actors.{{id}}.currency" {{#if tracking.currency}}checked{{/if}}>
</td>
</tr>
{{/each}}
</tbody>
</table>
<footer>
<button type="submit"><i class="fas fa-save"></i> Save</button>
</footer>
</form>