feat: Add Daggerheart actor item updater module for automatic and manual item synchronization with compendiums.

This commit is contained in:
CPTN Cosmo 2026-01-17 16:00:51 +01:00
commit 1c5f990d64
No known key found for this signature in database
7 changed files with 594 additions and 0 deletions

112
scripts/apps/updater-app.js Normal file
View file

@ -0,0 +1,112 @@
import { DHUpdater } from '../updater.js';
export class ActorUpdaterApp extends Application {
constructor(updates, options = {}) {
super(options);
this.updates = updates;
this.summaryLog = [];
this.isComplete = false;
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: 'dh-actor-updater',
title: 'Daggerheart Actor Updater',
template: 'modules/dh-actor-updater/templates/updater.hbs', // We'll need to register this or inject HTML
width: 600,
height: 500,
resizable: true,
classes: ['dh-actor-updater-window']
});
}
// Since I didn't verify if I can easily register templates, I'll use a render hook or just inject HTML string if template loading fails,
// but standard module structure supports templates. For simplicity and speed without extra file lookups, I'll override _render to simple HTML or create the template file.
// Let's create the template file properly.
getData() {
// If all updates are processed (null), show summary
const remaining = this.updates.filter(u => u !== null).length;
if (remaining === 0 && this.summaryLog.length > 0) {
this.isComplete = true;
}
return {
summary: this.isComplete,
summaryLog: this.summaryLog,
updates: this.updates.map((u, i) => {
if (!u) return null;
return {
id: i,
actorName: u.actor.name,
itemName: u.item.name,
itemImg: u.item.img,
changes: Object.keys(u.diff).length
};
})
};
}
activateListeners(html) {
super.activateListeners(html);
html.find('.btn-update').click(async (ev) => {
const idx = $(ev.currentTarget).data('index');
await this._doUpdate(idx);
});
html.find('.btn-ignore').click(async (ev) => {
const idx = $(ev.currentTarget).data('index');
await this._doIgnore(idx);
});
html.find('.btn-update-all').click(async () => {
const confirmed = await Dialog.confirm({
title: "Confirm Update All",
content: "<p>Are you sure you want to update <strong>ALL</strong> listed items? This will overwrite their data with the SRD Compendium versions. This cannot be undone.</p>",
defaultYes: false
});
if (!confirmed) return;
// Clone array indices to iterate safely
const indices = this.updates.map((u, i) => u ? i : -1).filter(i => i >= 0);
for (let i of indices) {
await this._doUpdate(i, false); // Don't re-render on each step
}
this.render();
});
}
async _doUpdate(index, render = true) {
const update = this.updates[index];
if (!update) return;
await DHUpdater.updateItem(update.item, update.compendiumItem);
const msg = `Updated ${update.item.name} on ${update.actor.name}`;
this.summaryLog.push(msg);
ui.notifications.info(msg);
// Remove from list
this.updates[index] = null;
if (render) this.render();
}
async _doIgnore(index) {
const update = this.updates[index];
if (!update) return;
const ignored = game.settings.get('dh-actor-updater', 'ignoredItems') || {};
ignored[update.item.uuid] = true;
await game.settings.set('dh-actor-updater', 'ignoredItems', ignored);
const msg = `Ignored ${update.item.name} on ${update.actor.name}`;
this.summaryLog.push(msg);
ui.notifications.info(msg);
// Remove from list
this.updates[index] = null;
this.render();
}
}

92
scripts/main.js Normal file
View file

@ -0,0 +1,92 @@
import { DHUpdater } from './updater.js';
import { ActorUpdaterApp } from './apps/updater-app.js';
Hooks.once('init', () => {
game.settings.register('dh-actor-updater', 'ignoredItems', {
name: 'Ignored Items',
scope: 'world',
config: false,
type: Object,
default: {}
});
game.settings.register('dh-actor-updater', 'lastCheckedVersion', {
name: 'Last Checked System Version',
scope: 'world',
config: false,
type: String,
default: '0.0.0'
});
game.settings.registerMenu('dh-actor-updater', 'manualCheck', {
name: 'Check for Updates',
label: 'Check Now',
hint: 'Manually check for updates to your actors against the compendium.',
icon: 'fas fa-search',
type: ManualCheckConfig,
restricted: true
});
});
class ManualCheckConfig extends FormApplication {
render() {
game.modules.get('dh-actor-updater').api.checkAll();
// Don't actually render a form
return this;
}
}
Hooks.on('ready', async () => {
if (!game.user.isGM) return;
// Manual check hook exposed to API
game.modules.get('dh-actor-updater').api = {
checkAll: async () => {
await checkAndPrompt();
}
};
// Auto-check logic
const lastVersion = game.settings.get('dh-actor-updater', 'lastCheckedVersion');
const currentVersion = game.system.version;
if (currentVersion !== lastVersion) {
console.log("Daggerheart Actor Updater | System update detected. Checking for item updates...");
await checkAndPrompt();
await game.settings.set('dh-actor-updater', 'lastCheckedVersion', currentVersion);
} else {
// Optional: Run on every startup? User "re-prompt again on manual check", not necessarily every startup unless version change.
// "First run check" imply checking if it hasn't been checked before (implied by version 0.0.0).
// "Re-prompt on system version update" -> Covered.
// "Manual check" -> Covered by API.
// Let's add a setting for "Check on every startup" if wanted, but instructions implied first run or version update.
// "module should on first run check" -> handled by version diff check.
}
});
async function checkAndPrompt() {
ui.notifications.info("Daggerheart Actor Updater | Checking for updates...");
// Collect updates from all actors
// "configured actors" - likely means all actors or player characters.
// Let's check all actors the GM has permission to update (which is all)
let allUpdates = [];
for (const actor of game.actors) {
// Maybe restrict to Characters/Companions?
// "configured actors" - usually implies Characters. Adversaries in world might not need updates or might be customizations.
// Let's check 'character' and 'companion' types primarily, but maybe 'adversary' too if they are in the world.
// User didn't strictly specify, but usually players care about their sheets.
if (['character', 'companion'].includes(actor.type)) {
const updates = await DHUpdater.checkActor(actor);
allUpdates = allUpdates.concat(updates);
}
}
if (allUpdates.length > 0) {
new ActorUpdaterApp(allUpdates).render(true);
} else {
ui.notifications.info("Daggerheart Actor Updater | No updates found.");
}
}

171
scripts/updater.js Normal file
View file

@ -0,0 +1,171 @@
export class DHUpdater {
static PACK_MAPPING = {
'class': 'daggerheart.classes',
'subclass': 'daggerheart.subclasses',
'ancestry': 'daggerheart.ancestries',
'community': 'daggerheart.communities',
'domainCard': 'daggerheart.domains',
'weapon': 'daggerheart.weapons',
'armor': 'daggerheart.armors',
'consumable': 'daggerheart.consumables',
'loot': 'daggerheart.loot',
'beastform': 'daggerheart.beastforms'
};
static IGNORED_FIELDS = [
'_id',
'sort',
'ownership',
'flags',
'_stats',
'system.quantity',
'system.equipped',
'system.attuned',
'system.favorite',
'system.features',
'system.subclasses',
'system.inventory',
'system.characterGuide'
];
/**
* Check for updates for a specific actor
* @param {Actor} actor
* @returns {Promise<Array>} List of updates found
*/
static async checkActor(actor) {
if (!actor) return [];
const updates = [];
const ignoredItems = game.settings.get('dh-actor-updater', 'ignoredItems') || {};
for (const item of actor.items) {
// Skip if ignored
if (ignoredItems[item.uuid]) continue;
const packId = this.PACK_MAPPING[item.type];
if (!packId) continue; // Not a trackable item type
const pack = game.packs.get(packId);
if (!pack) continue;
// Try to find the source item in the compendium
// We search by name first.
// Ideally we'd match by flags.core.sourceId if available,
// but Daggerheart items might have just been dropped without keeping link,
// or we want to match by name for "generic" updates.
// The user prompt implies "compare them to the SRD compendium version", likely by name or origin.
let compendiumItem = null;
const sourceId = item.flags.core?.sourceId;
if (sourceId && sourceId.startsWith(packId)) {
const id = sourceId.split('.').pop();
compendiumItem = await pack.getDocument(id);
}
if (!compendiumItem) {
// Fallback to name match
const index = pack.index.find(i => i.name === item.name);
if (index) {
compendiumItem = await pack.getDocument(index._id);
}
}
if (compendiumItem) {
const diff = this.compareItems(item, compendiumItem);
if (diff) {
updates.push({
actor: actor,
item: item,
compendiumItem: compendiumItem,
diff: diff
});
}
}
}
return updates;
}
/**
* Compare actor item with compendium item
* @param {Item} item
* @param {Item} compendiumItem
* @returns {Object|null} Diff object or null if identical
*/
static compareItems(item, compendiumItem) {
const itemData = item.toObject();
const compendiumData = compendiumItem.toObject();
const diff = {};
let hasChanges = false;
// Basic fields
if (itemData.name !== compendiumData.name) {
// If we matched by ID, name might have changed
diff.name = { old: itemData.name, new: compendiumData.name };
hasChanges = true;
}
if (itemData.img !== compendiumData.img) {
diff.img = { old: itemData.img, new: compendiumData.img };
hasChanges = true;
}
// System data comparison
const flatItemSystem = foundry.utils.flattenObject(itemData.system);
const flatCompendiumSystem = foundry.utils.flattenObject(compendiumData.system);
for (const [key, value] of Object.entries(flatCompendiumSystem)) {
// Check for ignored fields
if (this.IGNORED_FIELDS.some(ignored => key.startsWith(ignored) || `system.${key}` === ignored)) continue;
// Should probably check if the field exists in compendium but is different in item
if (flatItemSystem[key] !== value) {
// Check if it's "effectively" the same (e.g. null vs undefined or html differences)
// Simple strict equality for now
// Handle embedded arrays explicitly??
// flattenObject handles arrays with numeric indices e.g. "array.0.field"
diff[`system.${key}`] = { old: flatItemSystem[key], new: value };
hasChanges = true;
}
}
// Also check if item has keys that compendium doesn't (removed fields)
// Might be tricky if users added custom data.
// We generally care if the Compendium version is "Standard" and we want to enforce it.
return hasChanges ? diff : null;
}
static async updateItem(item, compendiumItem) {
const itemData = compendiumItem.toObject();
delete itemData._id;
delete itemData.ownership;
delete itemData.folder;
delete itemData.sort;
delete itemData.flags;
delete itemData._stats;
// Fields to preserve from Actor (do not overwrite with Compendium data)
const PRESERVED_SYSTEM_FIELDS = [
// State fields
'quantity', 'equipped', 'attuned', 'favorite',
// Link fields (referencing other items)
'features', 'subclasses', 'inventory', 'characterGuide'
];
if (itemData.system) {
for (const field of PRESERVED_SYSTEM_FIELDS) {
if (field in itemData.system) {
delete itemData.system[field];
}
}
}
return item.update(itemData);
}
}