fix(gowlers-tracking-ledger): preserve value sign for correct damage classification

- Fix critical bug where damage was classified as 'Healing' instead of 'Damage'
- Changed queue storage to preserve sign of value (negative=damage, positive=healing)
- This allows buildSourceLabel() to correctly classify HP changes as damage vs healing
- Update version to 0.1.18

The issue was storing Math.abs(value) which stripped the sign. Now storing raw value
so that damage classification logic can use: value < 0 ? "Damage" : "Healing"

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
centron\schwoerer
2025-11-20 14:31:24 +01:00
parent 0ed21afba9
commit cc58627342
2 changed files with 405 additions and 41 deletions

View File

@@ -1,6 +1,6 @@
const MODULE_ID = "gowlers-tracking-ledger"; const MODULE_ID = "gowlers-tracking-ledger";
const MODULE_VERSION = "0.1.13"; const MODULE_VERSION = "0.1.18";
const TRACK_SETTING = "actorSettings"; const TRACK_SETTING = "actorSettings";
const FLAG_SCOPE = "world"; const FLAG_SCOPE = "world";
const MAX_HISTORY_ROWS = 100; const MAX_HISTORY_ROWS = 100;
@@ -78,6 +78,8 @@ const ledgerState = {
actorSettings: null, actorSettings: null,
lastCombatId: null, // Track last combat ID to link XP gains after combat ends lastCombatId: null, // Track last combat ID to link XP gains after combat ends
lastCombatEndTime: 0, // Timestamp when combat ended lastCombatEndTime: 0, // Timestamp when combat ended
openDialogs: new Map(), // actorId -> dialog instance (for live updates)
recentMessages: [], // Array of recent message metadata: { message, timestamp, source }
}; };
// Global tab switching function for history dialog // Global tab switching function for history dialog
@@ -118,6 +120,90 @@ window.switchHistoryTab = function(tabId) {
} }
}; };
// Global function for history dialog pagination navigation
window.historyPageNav = function(button, direction) {
const panel = button.closest('[data-history-panel]');
if (!panel) return;
const currentPage = parseInt(panel.getAttribute('data-page') || '1');
const pageInfo = panel.querySelector('[data-page-info]');
if (!pageInfo) return;
const pageMatch = pageInfo.textContent.match(/Page (\d+) \/ (\d+)/);
if (!pageMatch) return;
const totalPages = parseInt(pageMatch[2]);
let newPage = currentPage;
if (direction === 'next' && currentPage < totalPages) {
newPage = currentPage + 1;
} else if (direction === 'prev' && currentPage > 1) {
newPage = currentPage - 1;
}
if (newPage !== currentPage) {
panel.setAttribute('data-page', newPage);
console.log("[GowlersTracking] History pagination - moving to page:", newPage);
// Rebuild the table with new page
const root = panel.closest('[data-history-root]');
if (root) {
const app = root.closest('.app, .window-app, .dialog, [role="dialog"]');
if (app && app.__vue__?.constructor?.name === 'Dialog') {
// Re-render the dialog to show new page
app.__vue__.render();
}
}
}
};
// Global function for history dialog page size changes
window.historyChangePageSize = function(select) {
const pageSize = select.value;
const panel = select.closest('[data-history-panel]');
if (!panel) return;
// Reset to page 1 when changing page size
panel.setAttribute('data-page', '1');
panel.setAttribute('data-page-size', pageSize);
console.log("[GowlersTracking] History page size changed to:", pageSize);
// Re-render dialog
const root = panel.closest('[data-history-root]');
if (root) {
const app = root.closest('.app, .window-app, .dialog, [role="dialog"]');
if (app && app.__vue__?.constructor?.name === 'Dialog') {
app.__vue__.render();
}
}
};
// Function to refresh open dialogs with new data
function refreshOpenDialogs(actorId) {
const dialog = ledgerState.openDialogs.get(actorId);
if (!dialog || !dialog._state || !dialog.rendered) {
return;
}
console.log("[GowlersTracking] Refreshing dialog for actor:", actorId);
try {
// Get actor
const actor = game.actors.get(actorId);
if (!actor) return;
// Rebuild content
const content = buildHistoryContent(actor, "hp");
dialog.data.content = content;
// Re-render the dialog
dialog.render(false);
} catch (err) {
console.error("[GowlersTracking] Error refreshing dialog:", err);
}
}
Hooks.once("init", () => { Hooks.once("init", () => {
if (game.system.id !== "pf1") return; if (game.system.id !== "pf1") return;
@@ -133,6 +219,52 @@ Hooks.once("ready", async () => {
async function initializeModule() { async function initializeModule() {
if (globalThis.GowlersTrackingLedger?.initialized) return; if (globalThis.GowlersTrackingLedger?.initialized) return;
// Install ActorPF.applyDamage wrapper NOW that system is ready
console.log("[GowlersTracking] Installing ActorPF.applyDamage wrapper...");
const ActorPF = pf1?.documents?.actor?.ActorPF;
console.log("[GowlersTracking] ActorPF lookup:", ActorPF ? "Found" : "Not found", "| Already wrapped:", ActorPF?._trackedByGowlers);
if (ActorPF && !ActorPF._trackedByGowlers) {
// Wrap the STATIC method (not the instance method!)
// PF1 system calls ActorPF.applyDamage() directly from chat cards
const original = ActorPF.applyDamage;
console.log("[GowlersTracking] Original static applyDamage:", original ? "Found" : "Not found");
ActorPF.applyDamage = async function wrappedApplyDamage(value, options = {}) {
console.log("[GowlersTracking] STATIC applyDamage called! value:", value, "options keys:", Object.keys(options));
// Store message metadata for later matching in updateActor
try {
const message = options.message;
if (message) {
const source = buildSourceLabel(value, options);
console.log("[GowlersTracking] Storing message for matching: source=", source, "value=", value);
ledgerState.recentMessages.push({
message: message,
source: source,
value: value, // Keep sign! Negative = damage, Positive = healing
timestamp: Date.now(),
});
// Keep only last 50 messages to prevent memory leak
if (ledgerState.recentMessages.length > 50) {
ledgerState.recentMessages.shift();
}
}
} catch (err) {
console.warn("[GowlersTracking] Failed to store message metadata", err);
}
// Call original static method
return original.call(this, value, options);
};
ActorPF._trackedByGowlers = true;
console.log("[GowlersTracking] ActorPF.applyDamage STATIC wrapper installed successfully");
} else {
console.log("[GowlersTracking] ActorPF wrapper skipped - ActorPF found:", !!ActorPF, "Already wrapped:", ActorPF?._trackedByGowlers);
}
await primeAllActors(); await primeAllActors();
ledgerState.updateHook = Hooks.on("updateActor", handleActorUpdate); ledgerState.updateHook = Hooks.on("updateActor", handleActorUpdate);
ledgerState.createHook = Hooks.on("createActor", handleCreateActor); ledgerState.createHook = Hooks.on("createActor", handleCreateActor);
@@ -150,6 +282,92 @@ async function initializeModule() {
Hooks.on("deleteCombat", (combat) => onCombatEnd(combat)); Hooks.on("deleteCombat", (combat) => onCombatEnd(combat));
Hooks.on("updateCombat", (combat) => onCombatUpdate(combat)); Hooks.on("updateCombat", (combat) => onCombatUpdate(combat));
// Helper: Build source label from damage/healing context
function buildSourceLabel(value, options) {
// Order of precedence (per Manual_dmgtracking.md):
// 1. identifiedInfo from chat message
// 2. Chat card metadata (actor/item)
// 3. Chat card flavor text
// 4. Fallbacks
const message = options?.message;
if (!message) {
console.log("[GowlersTracking] buildSourceLabel: No message in options");
return null;
}
console.log("[GowlersTracking] buildSourceLabel: Processing damage value:", value);
console.log("[GowlersTracking] buildSourceLabel: Message flags:", message.flags);
// Check for identifiedInfo (action name, item name)
if (message.flags?.pf1?.identifiedInfo) {
const identified = message.flags.pf1.identifiedInfo;
const actionName = identified.action || identified.actionName || identified.itemName;
const actorName = identified.actorName || "Unknown";
console.log("[GowlersTracking] Found identifiedInfo:", identified);
if (actionName) {
const label = value < 0 ? `Damage (${actorName}, ${actionName})` : `Healing (${actionName})`;
console.log("[GowlersTracking] Using identifiedInfo label:", label);
return label;
}
}
// Check for chat card metadata (actor + item responsible)
if (message.flags?.pf1?.metadata) {
const meta = message.flags.pf1.metadata;
const actorName = meta.actor?.name || meta.actorName || "Unknown";
const itemName = meta.item?.name || meta.itemName || "Attack";
console.log("[GowlersTracking] Found metadata:", meta);
if (value < 0) {
const label = `Damage (${actorName}, ${itemName})`;
console.log("[GowlersTracking] Using metadata label:", label);
return label;
} else {
const label = `Healing (${itemName})`;
console.log("[GowlersTracking] Using metadata healing label:", label);
return label;
}
}
// Check for flavor text (custom macros may only have flavor)
if (message.flavor) {
// Try to parse flavor for action names
const flavorMatch = message.flavor.match(/\*\*(.*?)\*\*|<strong>(.*?)<\/strong>/);
if (flavorMatch) {
const actionName = flavorMatch[1] || flavorMatch[2];
const label = value < 0 ? `Damage (${actionName})` : `Healing (${actionName})`;
console.log("[GowlersTracking] Using flavor text label:", label);
return label;
}
}
// Fallbacks
const fallback = value < 0 ? "Damage" : "Healing";
console.log("[GowlersTracking] Using fallback label:", fallback);
return fallback;
}
// Helper: Record damage source for later consumption
function noteDamageSource(value, options = {}) {
const actor = this; // 'this' is the actor whose HP is changing
console.log("[GowlersTracking] noteDamageSource called for", actor?.name, "with value:", value);
if (!actor?.id) {
console.log("[GowlersTracking] noteDamageSource: No actor ID");
return;
}
const label = buildSourceLabel(value, options);
if (!label) {
console.log("[GowlersTracking] noteDamageSource: No label generated");
return;
}
ledgerState.sources = ledgerState.sources || new Map();
ledgerState.sources.set(actor.id, { label, ts: Date.now() });
console.log("[GowlersTracking] Damage source noted for", actor.name, "->", label);
}
console.log("[GowlersTracking] Combat hooks registered: createCombat, deleteCombat, updateCombat"); console.log("[GowlersTracking] Combat hooks registered: createCombat, deleteCombat, updateCombat");
const api = { const api = {
@@ -338,11 +556,12 @@ function openHistoryDialog(actor, initialTab = "hp") {
{ {
title: `${actor.name}: Tracking Log`, title: `${actor.name}: Tracking Log`,
content, content,
buttons: { close: { label: "Close" } }, buttons: {},
}, },
{ {
width: 800, width: 800,
height: "auto", height: 550,
resizable: true,
classes: ["pf1-history-dialog"], classes: ["pf1-history-dialog"],
render: (html) => { render: (html) => {
// Handle both jQuery objects and DOM elements // Handle both jQuery objects and DOM elements
@@ -411,13 +630,113 @@ function openHistoryDialog(actor, initialTab = "hp") {
console.log("[GowlersTracking] Header not found or button already exists"); console.log("[GowlersTracking] Header not found or button already exists");
} }
} }
// Add resize button to header
const $header = $html.closest('.app, .window-app, .dialog, [role="dialog"]').find('.window-header');
if ($header.length && !$header.find('[data-history-resize-header]').length) {
const $resizeBtn = $('<button/>', {
type: 'button',
class: 'history-resize-header-btn',
'data-history-resize-header': 'true',
title: 'Resize Dialog',
html: '<i class="fas fa-expand"></i>',
css: {
border: 'none',
background: 'transparent',
color: '#999',
cursor: 'pointer',
padding: '4px 8px',
marginRight: '4px',
fontSize: '16px',
transition: 'color 0.2s'
}
});
$resizeBtn.on('mouseenter', function() {
$(this).css('color', '#333');
}).on('mouseleave', function() {
$(this).css('color', '#999');
});
// Find the close button and insert before it
const $closeBtn = $header.find('.close');
if ($closeBtn.length) {
$closeBtn.before($resizeBtn);
console.log("[GowlersTracking] Resize button added to header");
} else {
$header.append($resizeBtn);
console.log("[GowlersTracking] Resize button appended to header");
}
}
}, },
} }
); );
// Register dialog for live updates
ledgerState.openDialogs.set(actor.id, dialog);
console.log("[GowlersTracking] Dialog registered for actor:", actor.name);
// Remove from registry when dialog closes
const originalClose = dialog.close.bind(dialog);
dialog.close = function() {
ledgerState.openDialogs.delete(actor.id);
console.log("[GowlersTracking] Dialog unregistered for actor:", actor.name);
return originalClose();
};
dialog.render(true); dialog.render(true);
} }
// Helper function to build XP breakdown tooltip
function buildXpBreakdownTooltip(actor, xpEntry) {
if (!xpEntry || !xpEntry.encounterId) {
return "";
}
try {
const encounters = actor.getFlag(FLAG_SCOPE, ENCOUNTER_FLAG) ?? [];
const encounter = encounters.find(e => e.encounterID === xpEntry.encounterId);
if (!encounter) {
return `${xpEntry.diff} XP from encounter`;
}
// Build breakdown from encounter participants
let breakdown = [];
if (encounter.participants && encounter.participants.length > 0) {
// Resolve participant UUIDs/IDs to actor names
const participantNames = encounter.participants.slice(0, 2).map(p => {
// Try to resolve UUID or actor ID to name
if (p.startsWith("Actor.")) {
const participantActor = fromUuidSync(p);
return participantActor?.name || p.slice(0, 8);
} else if (typeof p === "string" && p.length > 20) {
// Likely a UUID - try to resolve
const participantActor = game.actors.get(p);
return participantActor?.name || p.slice(0, 8);
}
return p; // Already a name
});
const participantsStr = participantNames.join(", ");
const more = encounter.participants.length > 2 ? ` +${encounter.participants.length - 2} more` : "";
breakdown.push(`${xpEntry.diff} from: ${participantsStr}${more}`);
} else {
breakdown.push(`${xpEntry.diff} XP`);
}
// Add encounter status
if (encounter.status) {
breakdown.push(`Status: ${encounter.status}`);
}
return breakdown.join("\n");
} catch (err) {
console.error("[GowlersTracking] Error building XP tooltip:", err);
return `${xpEntry.diff} XP`;
}
}
function buildHistoryContent(actor, tabArg) { function buildHistoryContent(actor, tabArg) {
const initialTab = tabArg ?? "hp"; // Explicitly capture the tab parameter const initialTab = tabArg ?? "hp"; // Explicitly capture the tab parameter
console.log("[GowlersTracking] buildHistoryContent called with initialTab:", initialTab); console.log("[GowlersTracking] buildHistoryContent called with initialTab:", initialTab);
@@ -449,7 +768,7 @@ function buildHistoryContent(actor, tabArg) {
{ {
label: "Δ", label: "Δ",
render: (entry) => entry.diff, render: (entry) => entry.diff,
getTitle: (entry) => entry.damageBreakdown ? `${entry.damageBreakdown}` : "" getTitle: (entry) => buildXpBreakdownTooltip(actor, entry)
}, },
{ label: "Source", render: (entry) => entry.source ?? "Manual" }, { label: "Source", render: (entry) => entry.source ?? "Manual" },
{ label: "Encounter", render: (entry) => entry.encounterId ? entry.encounterId.slice(0, 8) : "N/A" }, { label: "Encounter", render: (entry) => entry.encounterId ? entry.encounterId.slice(0, 8) : "N/A" },
@@ -496,8 +815,21 @@ function buildHistoryContent(actor, tabArg) {
const entries = actor.getFlag(FLAG_SCOPE, cfg.flag) ?? []; const entries = actor.getFlag(FLAG_SCOPE, cfg.flag) ?? [];
const display = cfg.id === initialTab ? "block" : "none"; const display = cfg.id === initialTab ? "block" : "none";
const isActive = cfg.id === initialTab ? "active" : ""; const isActive = cfg.id === initialTab ? "active" : "";
return `<div class="history-panel tab ${isActive}" data-history-panel="${cfg.id}" style="display:${display}"> const rowsPerPage = 10; // Default rows per page for history dialog
${renderHistoryTable(entries, cfg.columns, cfg.id)} const totalPages = Math.ceil(entries.length / rowsPerPage);
return `<div class="history-panel tab ${isActive}" data-history-panel="${cfg.id}" data-page="1" style="display:${display}">
${renderHistoryTable(entries, cfg.columns, cfg.id, rowsPerPage, 1)}
<div class="history-panel-pagination" style="display: flex; gap: 8px; align-items: center; justify-content: center; margin-top: 12px; padding-top: 8px; border-top: 1px solid #e0e0e0;">
<button type="button" data-action="history-page" data-direction="prev" onclick="window.historyPageNav(this, 'prev')" style="padding: 4px 8px; cursor: pointer;" ${totalPages <= 1 ? 'disabled' : ''}>« Prev</button>
<span data-page-info style="font-size: 0.85em; min-width: 100px; text-align: center;">Page 1 / ${Math.max(1, totalPages)}</span>
<button type="button" data-action="history-page" data-direction="next" onclick="window.historyPageNav(this, 'next')" style="padding: 4px 8px; cursor: pointer;" ${totalPages <= 1 ? 'disabled' : ''}>Next »</button>
<select data-history-page-size onchange="window.historyChangePageSize(this)" style="padding: 2px 4px;">
<option value="10" selected>10 rows</option>
<option value="20">20 rows</option>
<option value="50">50 rows</option>
<option value="all">All</option>
</select>
</div>
</div>`; </div>`;
}) })
.join(""); .join("");
@@ -525,12 +857,18 @@ function buildHistoryContent(actor, tabArg) {
</section>`; </section>`;
} }
function renderHistoryTable(entries, columns, id) { function renderHistoryTable(entries, columns, id, rowsPerPage = 10, currentPage = 1) {
if (!entries.length) { if (!entries.length) {
return `<p class="history-empty">No ${id.toUpperCase()} history recorded.</p>`; return `<p class="history-empty">No ${id.toUpperCase()} history recorded.</p>`;
} }
const rows = entries // Calculate pagination
const itemsToShow = rowsPerPage === "all" ? entries.length : rowsPerPage;
const startIdx = (currentPage - 1) * itemsToShow;
const endIdx = startIdx + itemsToShow;
const paginatedEntries = entries.slice(startIdx, endIdx);
const rows = paginatedEntries
.map( .map(
(entry) => ` (entry) => `
<tr> <tr>
@@ -561,6 +899,24 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op
const diffValue = config.diff(previous, nextValue); const diffValue = config.diff(previous, nextValue);
// COMPREHENSIVE DEBUG LOGGING FOR DAMAGE REPORTING
console.log("[GowlersTracking] ===== recordHistoryEntry DEBUG =====");
console.log("[GowlersTracking] statId:", statId);
console.log("[GowlersTracking] Previous:", previous, "Next:", nextValue, "Diff:", diffValue);
console.log("[GowlersTracking] Actor:", actor.name, "(" + actor.id + ")");
console.log("[GowlersTracking] userId:", userId);
console.log("[GowlersTracking] Full options object:", options);
console.log("[GowlersTracking] Full change object:", change);
// Log all keys in options for inspection
if (Object.keys(options).length > 0) {
console.log("[GowlersTracking] Options keys:", Object.keys(options));
for (const key of Object.keys(options)) {
console.log(`[GowlersTracking] options.${key}:`, options[key]);
}
}
console.log("[GowlersTracking] ===== end debug =====");
// Determine encounter ID: use active combat, or if none, check if combat just ended // Determine encounter ID: use active combat, or if none, check if combat just ended
let encounterId = game.combat?.id ?? null; let encounterId = game.combat?.id ?? null;
console.log(`[GowlersTracking] Recording ${statId} change - Active combat: ${encounterId ? "Yes (" + encounterId + ")" : "No"}`); console.log(`[GowlersTracking] Recording ${statId} change - Active combat: ${encounterId ? "Yes (" + encounterId + ")" : "No"}`);
@@ -582,41 +938,38 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op
let sourceDetails = ""; let sourceDetails = "";
let damageBreakdown = ""; let damageBreakdown = "";
if (options?.pf1DamageData) { // For HP damage, search recent messages for matching damage application
// Try to get attacker actor and item information if (statId === "hp" && diffValue < 0) {
const attackerName = options?.pf1?.attackerName || "Unknown"; const damageAmount = Math.abs(diffValue);
const itemName = options?.pf1?.itemName || "Attack"; const now = Date.now();
source = "Damage";
sourceDetails = `${attackerName}, ${itemName}`;
// Extract damage breakdown if available // Look for matching message in recent queue (within 4 seconds)
if (options?.pf1DamageData?.rolls) { let matchedMessage = null;
const damageRolls = options.pf1DamageData.rolls; for (let i = ledgerState.recentMessages.length - 1; i >= 0; i--) {
const breakdown = []; const msg = ledgerState.recentMessages[i];
let total = 0; const timeSinceMessage = now - msg.timestamp;
for (const roll of damageRolls) {
if (roll.damageType && roll.value) { // Match if: within 4 seconds AND damage value is close (within 10% tolerance)
breakdown.push(`${roll.value} ${roll.damageType}`); const valueMatch = Math.abs(msg.value - damageAmount) <= Math.max(1, damageAmount * 0.1);
total += parseInt(roll.value) || 0;
if (timeSinceMessage < 4000 && valueMatch) {
matchedMessage = msg;
console.log("[GowlersTracking] Found matching message:", msg.source, "value:", msg.value, "vs", damageAmount);
ledgerState.recentMessages.splice(i, 1); // Remove matched message
break;
} }
} }
if (breakdown.length > 0) {
damageBreakdown = breakdown.join(", "); if (matchedMessage) {
source = matchedMessage.source;
damageBreakdown = `${damageAmount} HP`;
console.log(`[GowlersTracking] Using matched damage source:`, source);
} }
} }
} else if (options?.healing) {
const healerName = options?.pf1?.healerName || "Unknown"; // Fallback for unmatched HP changes
const itemName = options?.pf1?.itemName || "Healing"; if (source === "Manual" && statId === "hp") {
source = "Healing"; // Check if it's damage or healing based on the diff (fallback for manual HP changes)
sourceDetails = `${healerName}, ${itemName}`;
damageBreakdown = `Healed for ${Math.abs(diffValue)} HP`;
} else if (options?.pf1?.actionType === "spell") {
source = "Spell";
sourceDetails = options?.pf1?.itemName || "Spell";
} else if (statId === "xp" && diffValue > 0) {
source = "XP Award";
} else if (statId === "hp") {
// Check if it's damage or healing based on the diff
const hpDiff = parseInt(diffValue); const hpDiff = parseInt(diffValue);
if (hpDiff < 0) { if (hpDiff < 0) {
source = "Damage"; source = "Damage";
@@ -625,6 +978,14 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op
source = "Healing"; source = "Healing";
damageBreakdown = `${hpDiff} healing`; damageBreakdown = `${hpDiff} healing`;
} }
} else if (statId === "xp" && diffValue > 0) {
// XP gains - could be from encounter end or manual award
source = "XP Award";
damageBreakdown = `${diffValue} XP`;
} else if (statId === "currency" && diffValue !== 0) {
// Currency changes
source = diffValue > 0 ? "Gained" : "Spent";
damageBreakdown = `${Math.abs(diffValue)} currency`;
} }
// Format source with details if available // Format source with details if available
@@ -650,6 +1011,9 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op
await actor.update({ [`flags.${FLAG_SCOPE}.${config.flag}`]: existing }, { [MODULE_ID]: true }); await actor.update({ [`flags.${FLAG_SCOPE}.${config.flag}`]: existing }, { [MODULE_ID]: true });
// Refresh any open dialogs for this actor
refreshOpenDialogs(actor.id);
if (shouldSendChat(actor.id, statId)) { if (shouldSendChat(actor.id, statId)) {
sendChatNotification(statId, actor, previous, nextValue, entry); sendChatNotification(statId, actor, previous, nextValue, entry);
} }

View File

@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"title": "Gowler's Tracking Ledger", "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.", "description": "Adds HP/XP/Currency log buttons to PF1 sheets and opens the tracking dialog preloaded with the actor's logs.",
"version": "0.1.13", "version": "0.1.18",
"authors": [ "authors": [
{ "name": "Gowler", "url": "https://foundryvtt.com" } { "name": "Gowler", "url": "https://foundryvtt.com" }
], ],