diff --git a/src/macros_new/gowlers-tracking-ledger/foundry.gowlershome.dyndns.org/modules/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js b/src/macros_new/gowlers-tracking-ledger/foundry.gowlershome.dyndns.org/modules/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js new file mode 100644 index 00000000..2d6c88e2 --- /dev/null +++ b/src/macros_new/gowlers-tracking-ledger/foundry.gowlershome.dyndns.org/modules/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js @@ -0,0 +1,995 @@ + +const MODULE_ID = "gowlers-tracking-ledger"; +const MODULE_VERSION = "0.1.3"; +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 ENCOUNTER_FLAG = "pf1EncounterHistory"; + +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)); + }) + ); + + // Track combat encounters + Hooks.on("combatStart", (combat) => onCombatStart(combat)); + Hooks.on("combatEnd", (combat) => onCombatEnd(combat)); + Hooks.on("updateCombat", (combat) => onCombatUpdate(combat)); + + 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); + + const dialog = new Dialog( + { + title: `${actor.name}: Tracking Log`, + content, + buttons: { close: { label: "Close" } }, + }, + { + width: 800, + height: "auto", + classes: ["pf1-history-dialog"], + render: (html) => { + // Handle both jQuery objects and DOM elements + const $html = $(html); + const $root = $html.find('[data-history-root]'); + + if (!$root.length) { + console.warn("[GowlersTracking] Root not found"); + return; + } + + // Tab switching + $root.find('[data-history-tab]').off('click').on('click', function(e) { + e.preventDefault(); + const tabId = $(this).attr('data-history-tab'); + console.log("[GowlersTracking] Tab clicked:", tabId); + + // Remove active from all tabs and hide all panels + $root.find('[data-history-tab]').removeClass('active'); + $root.find('[data-history-panel]').hide(); + + // Add active to clicked tab and show its panel + $root.find(`[data-history-tab="${tabId}"]`).addClass('active'); + $root.find(`[data-history-panel="${tabId}"]`).show(); + }); + + // Add config icon to dialog header (GM only) + if (game.user?.isGM) { + const $header = $html.closest('.dialog').find('.dialog-header'); + if ($header.length && !$header.find('[data-history-config-header]').length) { + const $configBtn = $('