From 4f0b3af6e173bd776ef45a04b10e8fa4ae65665b Mon Sep 17 00:00:00 2001 From: "centron\\schwoerer" Date: Tue, 18 Nov 2025 14:32:01 +0100 Subject: [PATCH] zwischenstand --- .serena/memories/pf1e_damage_roll_guide.md | 451 ++++++ .../scripts/gowlers-tracking-ledger.js | 1351 ++++++++++------- .../templates/config.hbs | 92 +- src/macros_new/macro_activate-damage-meter.js | 322 ++++ src/macros_new/macro_reset-damage-meter.js | 79 + 5 files changed, 1721 insertions(+), 574 deletions(-) create mode 100644 .serena/memories/pf1e_damage_roll_guide.md create mode 100644 src/macros_new/macro_activate-damage-meter.js create mode 100644 src/macros_new/macro_reset-damage-meter.js diff --git a/.serena/memories/pf1e_damage_roll_guide.md b/.serena/memories/pf1e_damage_roll_guide.md new file mode 100644 index 00000000..b5a28228 --- /dev/null +++ b/.serena/memories/pf1e_damage_roll_guide.md @@ -0,0 +1,451 @@ +# PF1e Damage Roll Detection & Structure Guide + +## Overview + +Comprehensive guide to understanding how damage rolls are created, stored, and detected in the PF1e system for Foundry VTT v11.315. + +--- + +## 1. Damage Roll Classes + +### DamageRoll Class +**Location**: `module/dice/damage-roll.mjs` + +Extends `RollPF`. Damage rolls store damage-specific metadata: + +```javascript +export class DamageRoll extends RollPF { + static TYPES = { + NORMAL: "normal", + CRITICAL: "crit", + NON_CRITICAL: "nonCrit", + }; + + constructor(formula, data, options = {}) { + super(formula, data, options); + this.options.damageType ??= { values: ["untyped"], custom: "" }; + } + + get damageType() { + return this.options.damageType; // {values: ["fire"], custom: ""} + } + + get type() { + return this.options.type; // "normal", "crit", or "nonCrit" + } + + get isCritical() { + return this.type === this.constructor.TYPES.CRITICAL; + } +} +``` + +**Key Properties**: +- `options.damageType` - Object with damage types: `{values: string[], custom: string}` +- `options.type` - Critical status: "normal", "crit", or "nonCrit" +- Extends `RollPF` which extends Foundry's `Roll` class + +--- + +## 2. Chat Attack Structure + +### ChatAttack Class +**Location**: `module/action-use/chat-attack.mjs` + +Manages all rolls for a single attack action: + +```javascript +export class ChatAttack { + action; // ItemAction reference + hasDamage = false; + damage = new AttackDamage(); // Normal damage rolls + damageRows = []; // Formatted rows for display + nonlethal = false; + critDamage = new AttackDamage(); // Critical multiplier damage +} +``` + +### AttackDamage Class +Container for damage rolls: + +```javascript +class AttackDamage { + flavor = ""; // "Damage", "Healing", "Nonlethal" + total = 0; // Total damage sum + rolls = []; // Array of DamageRoll instances + + get isActive() { + return this.rolls.length > 0; + } + + get half() { + return Math.floor(this.total / 2); + } +} +``` + +--- + +## 3. Chat Message Metadata Storage + +### Flag Path +**Key Location**: `chatMessage.flags.pf1.metadata.rolls` + +All PF1-specific data stored in the message flags hierarchy: + +```javascript +{ + actor: "", + item: "", + action: "", + template: "", + targets: ["", ...], + combat: "", + + rolls: { + attacks: [ + { + // Attack roll (d20) + attack: {class: "D20RollPF", formula: "1d20 + 5", total: 18, ...}, + + // Normal damage rolls + damage: [ + { + class: "DamageRoll", + formula: "1d8 + 3[Strength]", + type: "normal", + options: { + damageType: {values: ["slashing"], custom: ""}, + type: "normal" + }, + total: 7, + ... + } + ], + + // Critical confirmation roll + critConfirm: null, // Or D20RollPF if confirmed + + // Critical damage (only if crit confirmed) + critDamage: [ + { + class: "DamageRoll", + formula: "1d8 + 3[Strength]", + type: "crit", + total: 6, + ... + } + ], + + // Ammo tracking (ranged only) + ammo: {id: "", quantity: 1, misfire: false} + } + ] + }, + + // Save DC for spells + save: {dc: 16, type: "reflex"}, + + // Spell-specific + spell: {cl: 5, sl: 2}, + + // Config data + config: {critMult: 2} +} +``` + +--- + +## 4. Damage Roll Creation Flow + +``` +ActionUse.perform() + ↓ +generateChatAttacks() + ↓ +addAttacks() OR addDamage() + ↓ +ChatAttack.addDamage({flavor, extraParts, critical, conditionalParts}) + ↓ +action.rollDamage() → returns DamageRoll[] + ↓ +Stored in ChatAttack.damage.rolls[] or ChatAttack.critDamage.rolls[] + ↓ +generateChatMetadata() → converts to JSON for storage + ↓ +ChatMessage.flags.pf1.metadata.rolls.attacks[index] +``` + +--- + +## 5. Detecting Damage Rolls + +### From ChatMessage + +```javascript +// Access metadata +const metadata = message.flags?.pf1?.metadata; + +// Check if damage exists +const hasAttacks = metadata?.rolls?.attacks?.length > 0; + +if (hasAttacks) { + const attack = metadata.rolls.attacks[0]; + + // Normal damage rolls (array) + const normalDamage = attack.damage; // Each item is serialized DamageRoll + + // Critical damage rolls (array, if critical hit) + const critDamage = attack.critDamage; + + // Attack roll (d20) + const attackRoll = attack.attack; +} + +// Reconstruct Roll objects from JSON +const damageRollData = metadata.rolls.attacks[0].damage[0]; +const damageRoll = Roll.fromData(damageRollData); +``` + +### SimpleDetection Pattern + +```javascript +// Check if message has PF1 attack card with damage +function hasDamageRolls(message) { + return !!message.flags?.pf1?.metadata?.rolls?.attacks?.some( + atk => atk.damage?.length > 0 + ); +} + +// Get total damage +function getTotalDamage(message) { + const metadata = message.flags?.pf1?.metadata; + if (!metadata?.rolls?.attacks?.length) return 0; + + return metadata.rolls.attacks.reduce((total, attack) => { + const normal = attack.damage?.reduce((sum, r) => sum + r.total, 0) ?? 0; + const crit = attack.critDamage?.reduce((sum, r) => sum + r.total, 0) ?? 0; + return total + normal + crit; + }, 0); +} +``` + +--- + +## 6. Damage Roll Properties + +### Stored in Metadata (JSON) + +```javascript +// Each damage roll in metadata contains: +{ + class: "DamageRoll", + formula: "1d8 + 3[Strength]", + type: "normal", // or "crit" or "nonCrit" + result: [{class: "DiceTerm", ...}, ...], + total: 7, + _formula: "1d8 + 3[Strength]", + _evaluated: true, + + // Options (may need reconstruction from Roll) + options: { + damageType: { + values: ["slashing"], + custom: "" + }, + type: "normal" + } +} +``` + +### On Reconstructed DamageRoll + +```javascript +const rollData = message.flags.pf1.metadata.rolls.attacks[0].damage[0]; +const roll = Roll.fromData(rollData); + +// Direct access +console.log(roll.formula); // "1d8 + 3[Strength]" +console.log(roll.total); // 7 + +// If DamageRoll instance +if (roll instanceof pf1.dice.DamageRoll) { + console.log(roll.damageType); // {values: ["slashing"], custom: ""} + console.log(roll.type); // "normal" + console.log(roll.isCritical); // false +} +``` + +--- + +## 7. Critical vs Non-Critical Detection + +```javascript +function analyzeDamageBreakdown(message) { + const metadata = message.flags?.pf1?.metadata; + const result = {normalTotal: 0, critTotal: 0, attacks: []}; + + metadata?.rolls?.attacks?.forEach((attack, idx) => { + const attackData = { + index: idx, + normalDamage: [], + criticalDamage: [] + }; + + // Normal damage + if (attack.damage?.length) { + attack.damage.forEach(roll => { + attackData.normalDamage.push({ + formula: roll.formula, + total: roll.total, + type: roll.type, // "normal" + damageType: roll.options?.damageType?.values + }); + result.normalTotal += roll.total; + }); + } + + // Critical damage + if (attack.critDamage?.length) { + attack.critDamage.forEach(roll => { + attackData.criticalDamage.push({ + formula: roll.formula, + total: roll.total, + type: roll.type, // "crit" + damageType: roll.options?.damageType?.values + }); + result.critTotal += roll.total; + }); + } + + result.attacks.push(attackData); + }); + + return result; +} +``` + +--- + +## 8. Damage Type Detection + +```javascript +function getDamageTypes(message) { + const metadata = message.flags?.pf1?.metadata; + const types = new Set(); + + metadata?.rolls?.attacks?.forEach(attack => { + attack.damage?.forEach(roll => { + roll.options?.damageType?.values?.forEach(type => types.add(type)); + }); + attack.critDamage?.forEach(roll => { + roll.options?.damageType?.values?.forEach(type => types.add(type)); + }); + }); + + return Array.from(types); +} +``` + +--- + +## 9. Healing vs Damage + +### Healing Identification + +```javascript +// Method 1: Simple heal rolls +function isHealingRoll(message) { + return message.flags?.pf1?.subject?.health === "healing"; +} + +// Method 2: Full attack card (need to check template data, not in metadata) +// Healing is indicated in ChatAttack.isHealing flag during creation +``` + +### Nonlethal Damage + +```javascript +// Nonlethal flag stored during attack execution +// Indicates damage reduced to minimum (1) +function isNonlethal(message) { + // Would need access to ChatAttack instance during creation + // Metadata doesn't preserve this flag post-creation + // Check formula comments or flavor text instead + return message.content?.includes("Nonlethal"); +} +``` + +--- + +## 10. ChatMessagePF Helpers + +**Location**: `module/documents/chat-message.mjs` + +```javascript +export class ChatMessagePF extends ChatMessage { + // Get source item of attack + get itemSource() { + const itemId = this.flags?.pf1?.metadata?.item; + if (itemId) { + const actor = this.constructor.getSpeakerActor(this.speaker); + return actor?.items.get(itemId); + } + return null; + } + + // Get source action + get actionSource() { + const actionId = this.flags?.pf1?.metadata?.action; + return actionId ? this.itemSource?.actions.get(actionId) : null; + } + + // Get targeted tokens + get targets() { + const targetIds = this.flags?.pf1?.metadata?.targets ?? []; + return targetIds.map(uuid => fromUuidSync(uuid)?.object).filter(t => !!t); + } + + // Reconstruct rolls from JSON + get systemRolls() { + return this._initRollObject(this.flags?.pf1?.metadata?.rolls ?? {}); + } +} +``` + +--- + +## 11. Key Files Reference + +| File | Purpose | +|------|---------| +| `module/dice/damage-roll.mjs` | DamageRoll class with damage-specific properties | +| `module/action-use/chat-attack.mjs` | ChatAttack / AttackDamage classes | +| `module/action-use/action-use.mjs` | Main flow and metadata generation (line 1300+) | +| `module/documents/chat-message.mjs` | ChatMessagePF with helpers | +| `module/utils/chat.mjs` | Chat utilities for rendering | + +--- + +## 12. Important Patterns + +### 1. Damage rolls ALWAYS stored in arrays +- `attack.damage[]` - multiple rolls possible +- `attack.critDamage[]` - critical hits also array + +### 2. Type property distinguishes roll category +- Roll `type: "normal"` = standard damage +- Roll `type: "crit"` = critical damage +- Roll `type: "nonCrit"` = non-critical special case + +### 3. Damage types in `options` object +- Access via `roll.options?.damageType?.values` array +- Multiple types possible: `["fire", "slashing"]` + +### 4. Critical damage includes normal damage +- When calculating total crit damage: add normal + critical totals +- `totalDamage = normal.reduce() + crit.reduce()` + +### 5. Nonlethal applies minimum damage rule +- If total < 1, becomes 1 and flagged nonlethal +- Happens during roll calculation, not stored in metadata diff --git a/src/macros_new/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js b/src/macros_new/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js index 3615450a..d3d35072 100644 --- a/src/macros_new/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js +++ b/src/macros_new/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js @@ -1,599 +1,838 @@ -(function () { - const MODULE_ID = "gowlers-tracking-ledger"; - const BUTTON_CLASS = "pf1-history-btn"; - const BUTTON_TITLE = "Open Log"; - const BUTTON_ICON = ''; - 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 MODULE_ID = "gowlers-tracking-ledger"; +const TRACK_SETTING = "actorSettings"; +const FLAG_SCOPE = "world"; +const MAX_HISTORY_ROWS = 100; +const BUTTON_CLASS = "pf1-history-btn"; +const BUTTON_TITLE = "Open Log"; +const BUTTON_ICON = ''; +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 DEFAULT_TRACKING = Object.freeze({ hp: true, xp: true, currency: true }); +const DEFAULT_CHAT = Object.freeze({ hp: false, xp: false, currency: false }); +const SETTINGS_VERSION = 2; +const COIN_ORDER = ["pp", "gp", "sp", "cp"]; - const LOG_TARGETS = [ - { key: "hp", tab: "hp", finder: findHpContainer }, - { key: "xp", tab: "xp", finder: findXpContainer }, - { key: "currency", tab: "currency", finder: findCurrencyContainer }, +const STAT_CONFIGS = { + hp: { + id: "hp", + label: "HP", + flag: "pf1HpHistory", + getter: (actor) => readNumericProperty(actor, "system.attributes.hp.value"), + equals: (a, b) => Object.is(a, b), + diff: (prev, next) => next - prev, + formatValue: (value) => `${value}`, + formatDiff: (diff) => (diff >= 0 ? `+${diff}` : `${diff}`), + clone: (value) => value, + }, + xp: { + id: "xp", + label: "XP", + flag: "pf1XpHistory", + getter: (actor) => readNumericProperty(actor, "system.details.xp.value"), + equals: (a, b) => Object.is(a, b), + diff: (prev, next) => next - prev, + formatValue: (value) => `${value}`, + formatDiff: (diff) => (diff >= 0 ? `+${diff}` : `${diff}`), + clone: (value) => value, + }, + currency: { + id: "currency", + label: "Currency", + flag: "pf1CurrencyHistory", + getter: (actor) => readCurrency(actor), + equals: currencyEquals, + diff: diffCurrency, + formatValue: (value) => formatCurrency(value, false), + formatDiff: (value) => formatCurrency(value, true), + clone: (value) => (value ? { ...value } : value), + }, +}; + +const LOG_TARGETS = [ + { id: "hp", finder: findHpHeader }, + { id: "xp", finder: findXpHeader }, + { id: "currency", finder: findCurrencyHeader }, +]; + +const SHEET_EVENTS = [ + "renderActorSheetPFCharacter", + "renderActorSheetPFNPC", + "renderActorSheetPFNPCLoot", + "renderActorSheetPFNPCLite", + "renderActorSheetPFTrap", + "renderActorSheetPFVehicle", + "renderActorSheetPFHaunt", + "renderActorSheetPFBasic", +]; + +const ledgerState = { + baselines: new Map(), // actorId -> { hp, xp, currency } + sheetObservers: new Map(), // `${sheetId}-${stat}` -> observer + sheetHooks: [], + updateHook: null, + createHook: null, + deleteHook: null, + actorSettings: null, +}; + +Hooks.once("init", () => { + if (game.system.id !== "pf1") return; + + registerSettings(); + registerSettingsMenu(); +}); + +Hooks.once("ready", async () => { + if (game.system.id !== "pf1") return; + await initializeModule(); +}); + +async function initializeModule() { + if (globalThis.GowlersTrackingLedger?.initialized) return; + + await primeAllActors(); + ledgerState.updateHook = Hooks.on("updateActor", handleActorUpdate); + ledgerState.createHook = Hooks.on("createActor", handleCreateActor); + ledgerState.deleteHook = Hooks.on("deleteActor", (actor) => ledgerState.baselines.delete(actor.id)); + + ledgerState.sheetHooks = SHEET_EVENTS.map((event) => + Hooks.on(event, (sheet) => { + const delays = [0, 100, 250]; + delays.forEach((delay) => setTimeout(() => attachButtons(sheet), delay)); + }) + ); + + const api = { + initialized: true, + openConfig: () => new TrackingLedgerConfig().render(true), + openConfigForActor: (actorId) => TrackingLedgerConfig.openForActor(actorId), + setActorTracking: setActorTracking, + getActorTracking, + }; + game.modules.get(MODULE_ID).api = api; + globalThis.GowlersTrackingLedger = api; +} + +function registerSettings() { + game.settings.register(MODULE_ID, TRACK_SETTING, { + scope: "world", + config: false, + type: Object, + default: {}, + }); +} + +function registerSettingsMenu() { + game.settings.registerMenu(MODULE_ID, "config", { + name: "Gowler's Tracking Ledger", + label: "Configure Tracking", + hint: "Choose which actors should track HP/XP/Currency logs.", + type: TrackingLedgerConfig, + restricted: true, + icon: "fas fa-book", + }); +} + +async function handleCreateActor(actor) { + await ensureActorConfig(actor); + primeActor(actor); +} + +function handleActorUpdate(actor, change, options, userId) { + if (options?.[MODULE_ID]) return; + if (!(actor instanceof Actor)) return; + + const tracking = getActorTracking(actor.id); + if (!tracking) return; + + const baselines = ledgerState.baselines.get(actor.id) ?? {}; + let changed = false; + + for (const statId of Object.keys(STAT_CONFIGS)) { + if (!tracking[statId]) continue; + const config = STAT_CONFIGS[statId]; + const nextValue = config.getter(actor); + if (nextValue === null || nextValue === undefined) continue; + + if (baselines[statId] === undefined) { + baselines[statId] = config.clone(nextValue); + continue; + } + + if (config.equals(baselines[statId], nextValue)) continue; + + const previous = baselines[statId]; + baselines[statId] = config.clone(nextValue); + changed = true; + recordHistoryEntry(actor, statId, previous, nextValue, userId).catch((err) => + console.error(`Tracking Ledger | Failed to append ${statId} entry`, err) + ); + } + + if (changed) ledgerState.baselines.set(actor.id, baselines); +} + +async function primeAllActors() { + const settings = getSettingsCache(); + let dirty = false; + for (const actor of game.actors.contents) { + if (!settings[actor.id]) { + settings[actor.id] = createActorConfig(); + dirty = true; + } + primeActor(actor); + } + if (dirty) { + await saveActorSettings(settings); + } +} + +function primeActor(actor, stats = Object.keys(STAT_CONFIGS)) { + if (!(actor instanceof Actor)) return; + const existing = ledgerState.baselines.get(actor.id) ?? {}; + for (const statId of stats) { + const config = STAT_CONFIGS[statId]; + const value = config.getter(actor); + if (value === null || value === undefined) continue; + existing[statId] = config.clone(value); + } + ledgerState.baselines.set(actor.id, existing); +} + +function attachButtons(sheet) { + if (!sheet?.element?.length || !sheet.actor) return; + for (const target of LOG_TARGETS) { + try { + const container = target.finder(sheet.element); + if (!container?.length) continue; + addButton(container, sheet, target.id); + } catch (err) { + console.warn(`Tracking Ledger | Failed to add ${target.id} button`, err); + } + } +} + +function addButton(container, sheet, statId) { + if (!container || !container.length || !sheet.actor) return; + if (container.find(`.${BUTTON_CLASS}[data-log-target="${statId}"]`).length) return; + + if (container.css("position") === "static") container.css("position", "relative"); + + const label = STAT_CONFIGS[statId]?.label ?? statId.toUpperCase(); + const button = $(` + + `); + button.on("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + openHistoryDialog(sheet.actor, statId); + }); + + container.append(button); + observeContainer(container, sheet, statId); +} + +function observeContainer(container, sheet, statId) { + const key = `${sheet.id}-${statId}`; + if (ledgerState.sheetObservers.has(key)) return; + if (!container[0]) return; + + const observer = new MutationObserver(() => addButton(container, sheet, statId)); + observer.observe(container[0], { childList: true }); + ledgerState.sheetObservers.set(key, observer); + + const cleanup = () => { + observer.disconnect(); + ledgerState.sheetObservers.delete(key); + sheet.element.off("remove", cleanup); + }; + sheet.element.on("remove", cleanup); +} + +function findHpHeader(root) { + let header = root.find(".info-box-header.health-details h3").first(); + if (header.length) return header; + header = root.find('[data-tooltip-extended="hit-points"] h3').first(); + if (header.length) return header; + return findHeaderByText(root, "Hit Points"); +} + +function findXpHeader(root) { + const experienceHeader = root.find(".info-box.experience h5").first(); + if (experienceHeader.length) return experienceHeader; + return findHeaderByText(root, "Experience"); +} + +function findCurrencyHeader(root) { + const currencySection = root.find('.tab.inventory ol.currency h3').first(); + if (currencySection.length) return currencySection; + return findHeaderByText(root, "Currency"); +} + +function findHeaderByText(root, text) { + const lower = text.toLowerCase(); + return root + .find("h1, h2, h3, h4, h5, label") + .filter((_, el) => el.textContent?.trim().toLowerCase() === lower) + .first(); +} + +function openHistoryDialog(actor, initialTab = "hp") { + if (!actor) return; + const content = buildHistoryContent(actor, initialTab); + new Dialog( + { + title: `${actor.name}: Log`, + content, + buttons: { close: { label: "Close" } }, + }, + { + width: 720, + classes: ["pf1-history-dialog"], + } + ).render(true); +} + +function buildHistoryContent(actor, initialTab = "hp") { + const canConfigure = game.user?.isGM; + const configs = [ + { + id: "hp", + label: "HP", + flag: STAT_CONFIGS.hp.flag, + columns: [ + { label: "Timestamp", render: (entry) => formatDate(entry.timestamp) }, + { label: "HP", render: (entry) => entry.value }, + { label: "Δ", render: (entry) => entry.diff }, + { label: "User", render: (entry) => entry.user ?? "" }, + { label: "Source", render: (entry) => entry.source ?? "" }, + ], + }, + { + id: "xp", + label: "XP", + flag: STAT_CONFIGS.xp.flag, + columns: [ + { label: "Timestamp", render: (entry) => formatDate(entry.timestamp) }, + { label: "XP", render: (entry) => entry.value }, + { label: "Δ", render: (entry) => entry.diff }, + { label: "User", render: (entry) => entry.user ?? "" }, + { label: "Source", render: (entry) => entry.source ?? "" }, + ], + }, + { + id: "currency", + label: "Currency", + flag: STAT_CONFIGS.currency.flag, + columns: [ + { label: "Timestamp", render: (entry) => formatDate(entry.timestamp) }, + { label: "Totals", render: (entry) => entry.value }, + { label: "Δ", render: (entry) => entry.diff }, + { label: "User", render: (entry) => entry.user ?? "" }, + { label: "Source", render: (entry) => entry.source ?? "" }, + ], + }, ]; - 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", - }; - }, - }, + const tabs = configs + .map( + (cfg) => + `${cfg.label}` + ) + .join(""); + + const panels = configs + .map((cfg) => { + const entries = actor.getFlag(FLAG_SCOPE, cfg.flag) ?? []; + const display = cfg.id === initialTab ? "block" : "none"; + return `
+ ${renderHistoryTable(entries, cfg.columns, cfg.id)} +
`; + }) + .join(""); + + const toolbar = canConfigure + ? `
+ +
` + : ""; + + return ` +
+ + ${toolbar} + +
${panels}
+ +
`; +} + +function renderHistoryTable(entries, columns, id) { + if (!entries.length) { + return `

No ${id.toUpperCase()} history recorded.

`; + } + + const rows = entries + .map( + (entry) => ` + + ${columns.map((col) => `${col.render(entry) ?? ""}`).join("")} + ` + ) + .join(""); + + return ` + + + ${columns.map((col) => ``).join("")} + + + ${rows} + +
${col.label}
`; +} + +async function recordHistoryEntry(actor, statId, previous, nextValue, userId) { + const config = STAT_CONFIGS[statId]; + if (!config) return; + + const diffValue = config.diff(previous, nextValue); + const entry = { + timestamp: Date.now(), + value: config.formatValue(nextValue), + diff: config.formatDiff(diffValue), + user: game.users.get(userId)?.name ?? "System", + source: "", }; - Hooks.once("init", () => { - registerSettings(); - }); + const existing = (await actor.getFlag(FLAG_SCOPE, config.flag)) ?? []; + existing.unshift(entry); + if (existing.length > MAX_HISTORY_ROWS) existing.splice(MAX_HISTORY_ROWS); - Hooks.once("ready", () => { - registerButtons(); - reconfigureTracking(); - }); + await actor.update({ [`flags.${FLAG_SCOPE}.${config.flag}`]: existing }, { [MODULE_ID]: true }); - function registerSettings() { - game.settings.register(MODULE_ID, "trackingConfig", { - scope: "world", - config: false, - type: Object, - default: {}, - }); + if (shouldSendChat(actor.id, statId)) { + sendChatNotification(statId, actor, previous, nextValue, entry); + } +} - 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 formatDate(ts) { + return ts ? new Date(ts).toLocaleString() : ""; +} + +function readNumericProperty(actor, path) { + const value = foundry.utils.getProperty(actor, path) ?? foundry.utils.getProperty(actor.system ?? {}, path.replace(/^system\./, "")); + if (value === undefined || value === null) return null; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +function readCurrency(actor) { + const base = + foundry.utils.getProperty(actor, "system.currency") ?? + foundry.utils.getProperty(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) { + if (!a || !b) return false; + 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 = {}, signed = false) { + return COIN_ORDER.map((coin) => { + const value = Number(obj[coin] ?? 0); + if (signed && value === 0) return null; + const formatted = signed ? (value > 0 ? `+${value}` : `${value}`) : `${value}`; + return `${coin}:${formatted}`; + }) + .filter(Boolean) + .join(" "); +} + +function checkboxValue(value, fallback = false) { + if (value === undefined || value === null) return fallback; + if (Array.isArray(value)) value = value[value.length - 1]; + if (typeof value === "string") { + const normalized = value.toLowerCase(); + if (["", "false", "0", "off", "no"].includes(normalized)) return false; + if (["true", "1", "on", "yes"].includes(normalized)) return true; + } + return Boolean(value); +} + +function createActorConfig(source = null) { + const config = { + version: SETTINGS_VERSION, + tracking: { ...DEFAULT_TRACKING }, + chat: { ...DEFAULT_CHAT }, + }; + if (!source) return config; + + if (source.version === SETTINGS_VERSION) { + config.tracking = { ...config.tracking, ...(source.tracking ?? {}) }; + config.chat = { ...config.chat, ...(source.chat ?? {}) }; + return config; + } + + // Legacy/bugged data: only preserve explicit true values so defaults re-enable tracking/chat + if (source.tracking) { + for (const [key, value] of Object.entries(source.tracking)) { + if (value === true) config.tracking[key] = true; + } + } + if (source.chat) { + for (const [key, value] of Object.entries(source.chat)) { + if (value === true) config.chat[key] = true; + } + } + return config; +} + +function loadActorSettings() { + const stored = foundry.utils.deepClone(game.settings.get(MODULE_ID, TRACK_SETTING) ?? {}); + for (const [actorId, cfg] of Object.entries(stored)) { + stored[actorId] = createActorConfig(cfg); + } + return stored; +} + +function getSettingsCache() { + if (!ledgerState.actorSettings) { + ledgerState.actorSettings = loadActorSettings(); + } + return ledgerState.actorSettings; +} + +async function saveActorSettings(settings) { + for (const entry of Object.values(settings)) { + if (!entry) continue; + entry.version = SETTINGS_VERSION; + entry.tracking ??= { ...DEFAULT_TRACKING }; + entry.chat ??= { ...DEFAULT_CHAT }; + } + ledgerState.actorSettings = settings; + await game.settings.set(MODULE_ID, TRACK_SETTING, settings); +} + +function getActorConfig(actorId) { + const settings = getSettingsCache(); + if (!settings[actorId]) { + settings[actorId] = createActorConfig(); + saveActorSettings(settings).catch((err) => console.error("Tracking Ledger | Failed to persist actor config", err)); + } + return settings[actorId]; +} + +function getActorTracking(actorId) { + const entry = getActorConfig(actorId); + return { ...entry.tracking }; +} + +function getActorChat(actorId) { + const entry = getActorConfig(actorId); + return { ...entry.chat }; +} + +async function ensureActorConfig(actor) { + if (!actor?.id) return; + const settings = getSettingsCache(); + if (!settings[actor.id]) { + settings[actor.id] = createActorConfig(); + await saveActorSettings(settings); + } + return settings[actor.id]; +} + +async function setActorTracking(actorId, partial) { + if (!actorId) return; + const settings = getSettingsCache(); + const entry = getActorConfig(actorId); + entry.tracking = { + hp: partial.hp ?? entry.tracking.hp, + xp: partial.xp ?? entry.tracking.xp, + currency: partial.currency ?? entry.tracking.currency, + }; + await saveActorSettings(settings); + const actor = game.actors.get(actorId); + if (actor) primeActor(actor, Object.keys(partial)); +} + +async function setActorChat(actorId, partial) { + if (!actorId) return; + const settings = getSettingsCache(); + const entry = getActorConfig(actorId); + entry.chat = { + hp: partial.hp ?? entry.chat.hp, + xp: partial.xp ?? entry.chat.xp, + currency: partial.currency ?? entry.chat.currency, + }; + await saveActorSettings(settings); +} + +function shouldSendChat(actorId, statId) { + const chat = getActorChat(actorId); + return !!chat[statId]; +} + +class TrackingLedgerConfig extends FormApplication { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: "gowlers-tracking-ledger-config", + title: "Gowler's Tracking Ledger", + template: `modules/${MODULE_ID}/templates/config.hbs`, + width: 600, + height: "auto", + closeOnSubmit: true, }); } - function reconfigureTracking() { - trackingConfig = duplicate(game.settings.get(MODULE_ID, "trackingConfig") ?? {}); + static PAGE_OPTIONS = [25, 50, 100, 250]; + static DEFAULT_PAGE_SIZE = 50; + static _lastFilter = ""; + static _lastPage = 0; + static _lastPageSize = 50; - 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); - } - } + constructor(...args) { + super(...args); + this._filter = TrackingLedgerConfig._lastFilter ?? ""; + this._page = TrackingLedgerConfig._lastPage ?? 0; + this._pageSize = TrackingLedgerConfig._lastPageSize ?? TrackingLedgerConfig.DEFAULT_PAGE_SIZE; + this._pageMeta = { totalPages: 1, hasPrev: false, hasNext: false }; + this._actorRefs = null; } - 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); + get pageSize() { + return this._pageSize ?? TrackingLedgerConfig.DEFAULT_PAGE_SIZE; + } + + async getData() { + const settings = getSettingsCache(); + const filter = (this._filter ?? "").trim().toLowerCase(); + if (!this._actorRefs) { + this._actorRefs = game.actors.contents + .map((actor) => ({ id: actor.id, name: actor.name, nameLower: actor.name.toLowerCase() })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + const source = this._actorRefs; + + const filtered = filter ? source.filter((ref) => ref.nameLower.includes(filter)) : source; + + const totalActors = filtered.length; + const pageSize = this.pageSize; + const totalPages = Math.max(1, Math.ceil(Math.max(totalActors, 1) / pageSize)); + this._page = Math.min(this._page, totalPages - 1); + + const start = this._page * pageSize; + const pageItems = filtered.slice(start, start + pageSize).map((ref) => { + const entry = settings[ref.id] ?? createActorConfig(); + return { + id: ref.id, + name: ref.name, + tracking: { ...entry.tracking }, + chat: { ...entry.chat }, + }; }); - } - async function processTrackerChange(tracker, actor, userId) { - const actorId = actor.id; - const newValue = tracker.readValue(actor); - if (newValue === null || newValue === undefined) return; + const showingFrom = totalActors ? start + 1 : 0; + const showingTo = totalActors ? start + pageItems.length : 0; + const hasPrev = this._page > 0; + const hasNext = this._page < totalPages - 1; - const prev = tracker.state.current.get(actorId); - if (prev === undefined) { - tracker.state.current.set(actorId, tracker.cloneValue ? tracker.cloneValue(newValue) : newValue); - return; - } + this._pageMeta = { totalPages, hasPrev, hasNext }; + TrackingLedgerConfig._lastFilter = this._filter; + TrackingLedgerConfig._lastPage = this._page; + TrackingLedgerConfig._lastPageSize = pageSize; - const isEqual = tracker.equal ? tracker.equal(prev, newValue) : Object.is(prev, newValue); - if (isEqual) return; + const pageOptions = TrackingLedgerConfig.PAGE_OPTIONS.map((value) => ({ + value, + selected: value === pageSize, + })); - 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); - } + const totalPagesDisplay = totalActors ? totalPages : 0; - 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.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); + return { + actors: pageItems, + filter: this._filter, + page: this._page, + pageSize, + pageOptions, + totalPages, + totalPagesDisplay, + totalActors, + showingFrom, + showingTo, + hasPrev, + hasNext, + displayPage: totalActors ? this._page + 1 : 0, }; - 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(); - } + async _updateObject(_event, formData) { + const expanded = foundry.utils.expandObject(formData) ?? {}; + const actorPayload = expanded.actors ?? {}; + const rows = Object.entries(actorPayload).filter(([, cfg]) => cfg && Object.prototype.hasOwnProperty.call(cfg, "__present")); - 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; - } + if (!rows.length) return; - 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(); - } + const settings = getSettingsCache(); + let dirty = false; - 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"], + for (const [actorId, cfg] of rows) { + const entry = getActorConfig(actorId); + const nextTracking = { + hp: checkboxValue(cfg.tracking?.hp, false), + xp: checkboxValue(cfg.tracking?.xp, false), + currency: checkboxValue(cfg.tracking?.currency, false), + }; + const nextChat = { + hp: checkboxValue(cfg.chat?.hp, false), + xp: checkboxValue(cfg.chat?.xp, false), + currency: checkboxValue(cfg.chat?.currency, false), + }; + + if ( + entry.tracking.hp !== nextTracking.hp || + entry.tracking.xp !== nextTracking.xp || + entry.tracking.currency !== nextTracking.currency + ) { + entry.tracking = nextTracking; + dirty = true; } - ); - 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) => `${cfg.label}`) - .join(""); - - const panels = configs - .map((cfg) => { - const entries = actor.getFlag("world", cfg.flag) ?? []; - const table = renderHistoryTable(entries, cfg.columns, cfg.id); - return `
${table}
`; - }) - .join(""); - - return ` -
- - -
${panels}
-
`; - } - - 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 `

No ${id.toUpperCase()} history recorded.

`; - } - - const rows = entries - .map( - (entry) => ` - - ${columns.map((col) => `${col.render(entry) ?? ""}`).join("")} - ` - ) - .join(""); - - return ` - - - ${columns.map((col) => ``).join("")} - - - ${rows} - -
${col.label}
`; - } - - 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); + if (entry.chat.hp !== nextChat.hp || entry.chat.xp !== nextChat.xp || entry.chat.currency !== nextChat.currency) { + entry.chat = nextChat; + dirty = true; } } - 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; + if (dirty) { + await saveActorSettings(settings); } - const sys = foundry.utils.getProperty(actor.system ?? {}, "details.xp.value"); - if (sys !== undefined) { - const numeric = Number(sys); - return Number.isFinite(numeric) ? numeric : null; + for (const [actorId] of rows) { + const actor = game.actors.get(actorId); + if (actor) primeActor(actor); } - return null; } - // --------- Currency Helpers ---------- + activateListeners(html) { + super.activateListeners(html); + html.find("[data-filter-input]").on("input", (event) => { + this._filter = event.currentTarget.value ?? ""; + this._page = 0; + TrackingLedgerConfig._lastFilter = this._filter; + TrackingLedgerConfig._lastPage = this._page; + this.render(false); + }); - const COIN_ORDER = ["pp", "gp", "sp", "cp"]; + html.find("[data-page-size]").on("change", (event) => { + const value = Number(event.currentTarget.value); + this._pageSize = Number.isFinite(value) && value > 0 ? value : TrackingLedgerConfig.DEFAULT_PAGE_SIZE; + TrackingLedgerConfig._lastPageSize = this._pageSize; + this._page = 0; + TrackingLedgerConfig._lastPage = 0; + this.render(false); + }); - 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; + html.find("[data-action=\"page\"]").on("click", (event) => { + event.preventDefault(); + const direction = event.currentTarget.dataset.direction; + if (!direction) return; + const meta = this._pageMeta ?? { totalPages: 1 }; + if (direction === "prev" && !meta.hasPrev) return; + if (direction === "next" && !meta.hasNext) return; + const delta = direction === "next" ? 1 : -1; + this._page = Math.min(Math.max(this._page + delta, 0), Math.max(0, meta.totalPages - 1)); + TrackingLedgerConfig._lastPage = this._page; + this.render(false); + }); } - function currencyEquals(a, b) { - return COIN_ORDER.every((coin) => (a[coin] ?? 0) === (b[coin] ?? 0)); + static openForActor(actorId) { + const actor = game.actors.get(actorId); + const filter = actor?.name ?? ""; + TrackingLedgerConfig._lastFilter = filter; + TrackingLedgerConfig._lastPage = 0; + const app = new TrackingLedgerConfig(); + app._filter = filter; + app._page = 0; + app.render(true); } +} - function diffCurrency(prev, next) { - const diff = {}; - for (const coin of COIN_ORDER) diff[coin] = (next[coin] ?? 0) - (prev[coin] ?? 0); - return diff; - } +function sendChatNotification(statId, actor, previous, nextValue, entry) { + const config = STAT_CONFIGS[statId]; + if (!config) return; + const titles = { + hp: "HP", + xp: "XP", + currency: "Currency", + }; + const title = titles[statId] ?? statId.toUpperCase(); + const prevText = config.formatValue(previous); + const nextText = entry.value; + const content = ` + ${title} Log
+ ${actor.name}: ${prevText} -> ${nextText} (${entry.diff})
+ User: ${entry.user ?? "System"} + `; - 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 }; -})(); + ChatMessage.create({ + content, + speaker: { alias: "Tracking Ledger" }, + }).catch((err) => console.error("Tracking Ledger | Failed to post chat notification", err)); +} diff --git a/src/macros_new/gowlers-tracking-ledger/templates/config.hbs b/src/macros_new/gowlers-tracking-ledger/templates/config.hbs index 3bcd332b..658cc1da 100644 --- a/src/macros_new/gowlers-tracking-ledger/templates/config.hbs +++ b/src/macros_new/gowlers-tracking-ledger/templates/config.hbs @@ -1,28 +1,84 @@
+
+ + +
+ + {{#if totalActors}} + Page {{displayPage}} / {{totalPages}} + {{else}} + Page 0 / 0 + {{/if}} + +
+
+ {{#if totalActors}} + Showing {{showingFrom}}-{{showingTo}} of {{totalActors}} actors + {{else}} + No actors found. + {{/if}} +
+
- - - - + + + + + + + + + + + + - {{#each actors}} - - - - - - - {{/each}} + {{#if actors.length}} + {{#each actors}} + + + + + + + + + + {{/each}} + {{else}} + + + + {{/if}}
ActorHPXPCurrencyActorHPXPCurrency
TrackChatTrackChatTrackChat
{{name}} - - - - - -
+ {{name}} + + + + + + + + + + + + + +
No actors match the current filter.