initialize Ikonis system module with motherboard configuration and feature management UI

This commit is contained in:
CPTN Cosmo 2026-04-26 17:03:48 +02:00
commit 7bffeacaac
16 changed files with 1008 additions and 0 deletions

160
scripts/ikonis-config.js Normal file
View file

@ -0,0 +1,160 @@
import { DEFAULT_AUGMENTS, getAttachedFeature } from './ikonis-data.js';
export class IkonisAugmentConfig {
static async open() {
const augments = game.settings.get('dh-ikonis', 'augmentsList') || DEFAULT_AUGMENTS;
const defaultBondedUuid = game.settings.get('dh-ikonis', 'defaultBondedUuid') || "";
const processedAugments = [];
for (const a of augments) {
const aug = { ...a };
if (aug.featureUuid) {
const item = await getAttachedFeature(aug.featureUuid);
if (item) aug.featureName = item.name;
}
processedAugments.push(aug);
}
let bondedName = "";
if (defaultBondedUuid) {
const item = await getAttachedFeature(defaultBondedUuid);
if (item) bondedName = item.name;
}
const template = "modules/dh-ikonis/templates/ikonis-config.hbs";
const content = await foundry.applications.handlebars.renderTemplate(template, { augments: processedAugments, defaultBondedUuid, bondedName });
return foundry.applications.api.DialogV2.wait({
window: {
title: "Global Hardware Manager",
icon: "fa-solid fa-microchip",
width: 800,
height: 650,
resizable: true
},
content: content,
buttons: [
{
action: "add", label: "Add New", icon: "fa-solid fa-plus",
callback: () => { this._onAdd(); return false; }
},
{
action: "reset", label: "Reset", icon: "fa-solid fa-undo",
callback: () => { this._onReset(); return false; }
},
{
action: "save", label: "Save & Close", icon: "fa-solid fa-save",
callback: (event, button) => this._onSave(event, button)
}
],
render: (event, app) => {
const html = app.element;
const form = html.querySelector('form');
if (form) {
form.style.height = "100%";
form.style.maxHeight = "100%";
form.style.display = "flex";
form.style.flexDirection = "column";
form.style.overflow = "hidden";
}
const formContent = html.querySelector('.form-content');
if (formContent) {
formContent.style.flex = "1";
formContent.style.overflow = "hidden";
formContent.style.display = "flex";
formContent.style.flexDirection = "column";
}
html.querySelectorAll('[data-action="delete"]').forEach(el => {
el.addEventListener('click', () => this._onDelete(el.dataset.id, app));
});
html.addEventListener('drop', async (e) => {
// V14 namespaced TextEditor
const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(e);
if (data.type !== "Item") return;
const targetRow = e.target.closest('[data-id]');
const targetBonded = e.target.closest('.bonded-drop-zone');
if (targetBonded) {
await game.settings.set('dh-ikonis', 'defaultBondedUuid', data.uuid);
app.close(); this.open();
return;
}
if (targetRow) {
const id = targetRow.dataset.id;
const augs = game.settings.get('dh-ikonis', 'augmentsList') || [...DEFAULT_AUGMENTS];
const idx = augs.findIndex(a => String(a.id) === String(id));
if (idx !== -1) {
augs[idx].featureUuid = data.uuid;
await game.settings.set('dh-ikonis', 'augmentsList', augs);
}
app.close(); this.open();
}
});
html.querySelectorAll('[data-action="clearFeature"]').forEach(el => {
el.addEventListener('click', async (e) => {
const id = el.dataset.id;
const augs = game.settings.get('dh-ikonis', 'augmentsList') || [...DEFAULT_AUGMENTS];
const idx = augs.findIndex(a => String(a.id) === String(id));
if (idx !== -1) {
augs[idx].featureUuid = null;
await game.settings.set('dh-ikonis', 'augmentsList', augs);
}
app.close(); this.open();
});
});
}
});
}
static async _onAdd() {
const augments = game.settings.get('dh-ikonis', 'augmentsList') || [...DEFAULT_AUGMENTS];
augments.push({ id: foundry.utils.randomID(), name: "New Augment", effect: "Effect", cost: "Cost", precompile: 1 });
await game.settings.set('dh-ikonis', 'augmentsList', augments);
this.open();
}
static async _onDelete(id, app) {
const augments = (game.settings.get('dh-ikonis', 'augmentsList') || []).filter(a => String(a.id) !== String(id));
await game.settings.set('dh-ikonis', 'augmentsList', augments);
app.close(); this.open();
}
static async _onReset() {
const confirmed = await foundry.applications.api.DialogV2.confirm({
window: { title: "Reset" }, content: "Reset to defaults?", yes: { label: "Reset" }
});
if (confirmed) {
await game.settings.set('dh-ikonis', 'augmentsList', DEFAULT_AUGMENTS);
await game.settings.set('dh-ikonis', 'defaultBondedUuid', "");
this.open();
}
}
static async _onSave(event, button) {
// V14 namespaced FormDataExtended
const fde = new foundry.applications.ux.FormDataExtended(button.form);
const data = foundry.utils.expandObject(fde.object);
const currentAugs = game.settings.get('dh-ikonis', 'augmentsList') || [];
const augments = Object.entries(data.augments || {}).map(([id, val]) => {
const existing = currentAugs.find(a => String(a.id) === String(id));
return {
id,
name: val.name,
effect: val.effect,
cost: val.cost,
precompile: parseInt(val.precompile) || 1,
featureUuid: existing?.featureUuid || null
};
});
await game.settings.set('dh-ikonis', 'augmentsList', augments);
ui.notifications.info("Global Hardware saved!");
}
}

61
scripts/ikonis-data.js Normal file
View file

@ -0,0 +1,61 @@
export const DEFAULT_AUGMENTS = [
{ id: "force", name: "Kinetic Amplifier", effect: "+1 Damage on Melee attacks", cost: "2 Iron", precompile: 1 },
{ id: "fire", name: "Thermal Core", effect: "Deals Fire damage instead of Physical", cost: "1 Blaze Glass", precompile: 1 },
{ id: "shock", name: "Static Coil", effect: "Targets hit are Dazed", cost: "3 Copper", precompile: 2 },
{ id: "shield", name: "Reactive Plating", effect: "+1 Armor while equipped", cost: "2 Steel", precompile: 1 },
{ id: "range", name: "Long-Range Optics", effect: "Increases Range by 1 step", cost: "1 Lens", precompile: 2 },
{ id: "crit", "name": "Precision Chip", "effect": "+1 to Crit range", "cost": "1 Gold", "precompile": 3 },
{ id: "multi", "name": "Burst Module", "effect": "Can target 2 enemies (Half damage)", "cost": "2 Gears", "precompile": 4 },
{ id: "drain", "name": "Siphon Link", "effect": "Recover 1 Hope on kill", "cost": "1 Soul Gem", "precompile": 4 },
{ id: "weight", "name": "Gravity Plate", "effect": "Weapon is Heavy (more damage)", "cost": "4 Lead", "precompile": 2 }
];
export function getAugments() {
return game.settings.get('dh-ikonis', 'augmentsList') || DEFAULT_AUGMENTS;
}
export function getSlotCount(item) {
const flags = item.getFlag('dh-ikonis') || {};
if (typeof flags.slotOverride === "number") return flags.slotOverride;
// Daggerheart Tier detection (can be system.tier.value or system.tier)
let tier = item.system?.tier?.value;
if (tier === undefined) tier = item.system?.tier;
// Ensure we have a number
const tierNum = parseInt(tier) || 1;
console.log(`DH-Ikonis | Detecting slots for Tier ${tierNum} (Raw: ${tier})`);
const settingKey = `slotsTier${tierNum}`;
try {
const slots = game.settings.get('dh-ikonis', settingKey);
console.log(`DH-Ikonis | Setting [${settingKey}] returned: ${slots}`);
return slots;
} catch (e) {
return tierNum >= 2 ? 3 : 2;
}
}
/**
* Robust feature fetching with timeout to prevent sheet hangs.
*/
export async function getAttachedFeature(uuid) {
if (!uuid) return null;
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout fetching feature")), 2000)
);
try {
const item = await Promise.race([fromUuid(uuid), timeout]);
return item;
} catch (err) {
console.warn(`DH-Ikonis | Failed or timed out fetching feature [${uuid}]:`, err.message);
return null;
}
}
export function patchDHWeapon() {
console.log("DH-Ikonis | Patching DH Weapon system...");
}

180
scripts/ikonis-sheet.js Normal file
View file

@ -0,0 +1,180 @@
import { getAugments, getSlotCount, getAttachedFeature } from './ikonis-data.js';
/**
* Patches the Daggerheart Weapon sheet to include the Ikonis tab.
* This is more robust than extending the class in Foundry V14.
*/
export function patchIkonisSheet() {
const { Weapon } = game.system.api.applications.sheets.items || {};
if (!Weapon) {
console.error("DH-Ikonis | Weapon sheet class not found in system API!");
return;
}
console.log("DH-Ikonis | Patching Weapon sheet prototype...");
// 1. Add the Ikonis Tab to TABS (on the static class)
if (Weapon.TABS?.primary) {
const hasTab = Weapon.TABS.primary.tabs.some(t => t.id === 'motherboard');
if (!hasTab) {
Weapon.TABS.primary.tabs.push({
id: 'motherboard',
label: 'DAGGERHEART.ITEMS.Ikonis.Motherboard',
icon: 'fa-solid fa-microchip'
});
}
}
// 2. Add the Motherboard Part to PARTS
// We REMOVE the 'tab' property here to prevent Foundry from automatically hiding it
// instead, we will handle visibility in the template.
if (!Weapon.PARTS.motherboard) {
Weapon.PARTS.motherboard = {
template: 'modules/dh-ikonis/templates/ikonis-motherboard.hbs',
scrollable: ['.motherboard-content']
};
}
// 3. Patch _prepareContext
const originalPrepare = Weapon.prototype._prepareContext;
Weapon.prototype._prepareContext = async function(options) {
let context = await originalPrepare.call(this, options);
try {
const doc = this.document;
if (!doc) return context;
const installedIds = doc.getFlag('dh-ikonis', 'installedAugments') || [];
const allAugmentsList = getAugments() || [];
const processedAugments = [];
for (const id of installedIds) {
const base = allAugmentsList.find(a => String(a.id) === String(id));
if (!base) continue;
const aug = { ...base, installed: true };
if (aug.featureUuid) {
const item = await getAttachedFeature(aug.featureUuid);
if (item) aug.feature = { name: item.name, img: item.img, uuid: item.uuid };
}
processedAugments.push(aug);
}
const bondedUuid = doc.getFlag('dh-ikonis', 'bondedFeatureUuid') || game.settings.get('dh-ikonis', 'defaultBondedUuid');
const bonded = { enabled: true, feature: null };
if (bondedUuid) {
const item = await getAttachedFeature(bondedUuid);
if (item) bonded.feature = { name: item.name, img: item.img, uuid: item.uuid };
}
context.ikonis = {
enabled: true,
augments: processedAugments,
bonded: bonded,
isGM: game.user?.isGM || false
};
context.maxSlots = getSlotCount(doc);
context.usedSlots = processedAugments.length;
// Explicitly pass the active tab to the template
context.activeTab = this.tabGroups?.primary || "";
return context;
} catch (err) {
console.error("DH-Ikonis | Error in patched _prepareContext:", err);
return context;
}
};
// 4. Patch _onClickAction
const originalClick = Weapon.prototype._onClickAction;
Weapon.prototype._onClickAction = function(event, target) {
const action = target.dataset.action;
if (action === "addAugment") return this._onAddAugment(event, target);
if (action === "removeAugment") return this._onRemoveAugment(event, target);
return originalClick.call(this, event, target);
};
// 5. Add custom methods
Weapon.prototype._onAddAugment = async function(event, target) {
const installedIds = this.document.getFlag('dh-ikonis', 'installedAugments') || [];
const allAugments = getAugments();
const validInstalled = installedIds.filter(id => allAugments.some(a => String(a.id) === String(id)));
const maxSlots = getSlotCount(this.document);
if (validInstalled.length >= maxSlots) {
ui.notifications.warn(`No more augment slots available! (Max: ${maxSlots})`);
return;
}
const available = allAugments.filter(a => !validInstalled.includes(String(a.id)));
const content = `
<div class="augment-picker" style="max-height: 500px; display: flex; flex-direction: column; background: #0f0f1b; padding: 1rem; border-radius: 8px;">
<div class="picker-header" style="margin-bottom: 1rem;">
<input type="text" id="augment-search" placeholder="Search augments..." style="width: 100%; background: #1a1a2e; color: white; border: 1px solid #2d3436; padding: 0.5rem;">
</div>
<div class="picker-list" style="flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.75rem;">
${available.map(a => `
<div class="picker-item" data-id="${a.id}" style="border: 2px solid #2d3436; background: #1a1a2e; padding: 1rem; border-radius: 8px; cursor: pointer;">
<div class="augment-info">
<strong class="aug-name" style="display: block; font-size: 1.1rem; color: #ffffff;">${a.name}</strong>
<span style="display: block; margin: 2px 0; font-size: 0.9rem; color: #d1d8e0;">${a.effect}</span>
<small style="display: block; color: #888; font-style: italic;">Cost: ${a.cost}</small>
</div>
</div>
`).join('')}
</div>
</div>
`;
let selectedId = null;
const res = await foundry.applications.api.DialogV2.wait({
window: { title: "Install Tech" },
content: content,
buttons: [
{ action: "ok", label: "Install", icon: "fa-solid fa-download", callback: () => selectedId },
{ action: "cancel", label: "Cancel", icon: "fa-solid fa-times" }
],
render: (e, app) => {
const search = app.element.querySelector('#augment-search');
if (search) search.focus();
search?.addEventListener('input', (event) => {
const query = event.target.value.toLowerCase();
app.element.querySelectorAll('.picker-item').forEach(el => {
el.style.display = el.innerText.toLowerCase().includes(query) ? 'block' : 'none';
});
});
app.element.querySelectorAll('.picker-item').forEach(el => {
el.addEventListener('click', () => {
app.element.querySelectorAll('.picker-item').forEach(i => {
i.style.borderColor = "#2d3436";
i.style.background = "#1a1a2e";
i.classList.remove('selected');
});
el.style.borderColor = "#00d2ff";
el.style.background = "rgba(0, 210, 255, 0.15)";
el.classList.add('selected');
selectedId = el.dataset.id;
});
});
}
});
if (res && res !== "cancel") {
const newIds = [...validInstalled, String(res)];
await this.document.update({ "flags.dh-ikonis.installedAugments": newIds });
this.render(true);
}
};
Weapon.prototype._onRemoveAugment = async function(event, target) {
const installedIds = this.document.getFlag('dh-ikonis', 'installedAugments') || [];
const newIds = installedIds.filter(id => id !== String(target.dataset.id));
await this.document.update({ "flags.dh-ikonis.installedAugments": newIds });
this.render(true);
};
console.log("DH-Ikonis | Weapon sheet patched successfully.");
}

92
scripts/main.js Normal file
View file

@ -0,0 +1,92 @@
import { patchDHWeapon, DEFAULT_AUGMENTS } from './ikonis-data.js';
import { patchIkonisSheet } from './ikonis-sheet.js';
import { IkonisAugmentConfig } from './ikonis-config.js';
const MODULE_ID = 'dh-ikonis';
Hooks.once('init', () => {
console.log(`${MODULE_ID} | Initializing Ikonis Module`);
// Global Augments List
game.settings.register(MODULE_ID, "augmentsList", {
scope: "world",
config: false,
type: Array,
default: DEFAULT_AUGMENTS
});
// Default Bonded Feature
game.settings.register(MODULE_ID, "defaultBondedUuid", {
scope: "world",
config: false,
type: String,
default: ""
});
// Slot Scaling for Tiers 1, 2, 3, 4
[1, 2, 3, 4].forEach(tier => {
game.settings.register(MODULE_ID, `slotsTier${tier}`, {
name: `Slots at Tier ${tier}`,
hint: `How many augment slots a weapon gets at Tier ${tier}.`,
scope: "world",
config: true,
type: Number,
default: tier >= 2 ? 3 : 2
});
});
// Configuration Menu (V2 compliant)
game.settings.registerMenu(MODULE_ID, "augmentsMenu", {
name: "Manage Ikonis Augments",
label: "Open Augment Manager",
hint: "Add, remove, or edit the global list of Ikonis augments.",
icon: "fa-solid fa-microchip",
type: class extends foundry.applications.api.ApplicationV2 {
render() {
IkonisAugmentConfig.open();
return this;
}
},
restricted: true
});
});
Hooks.on('setup', () => {
patchDHWeapon();
patchIkonisSheet();
});
// Watch for Tier changes and force a refresh
Hooks.on('updateItem', (item, changes, options, userId) => {
// Check if system data or flags were updated
const isTierUpdate = foundry.utils.hasProperty(changes, "system.tier");
const isFlagUpdate = foundry.utils.hasProperty(changes, "flags.dh-ikonis");
if (isTierUpdate || isFlagUpdate) {
console.log(`DH-Ikonis | Update detected for ${item.name}. Re-rendering sheets...`);
// Find all active sheets for this item and force a full render
Object.values(item.apps || {}).forEach(app => {
if (app.render) app.render(true);
});
}
});
Hooks.once('ready', () => {
const actorsApi = game.system.api.models.actors || {};
const DhCharacter = actorsApi.DhCharacter || actorsApi.DHCharacter || actorsApi.character;
if (DhCharacter) {
Object.defineProperty(DhCharacter.prototype, 'primaryWeapon', {
get: function() {
return this.parent.items.find(x => x.type === 'weapon' && x.system.equipped && !x.system.secondary);
},
configurable: true
});
Object.defineProperty(DhCharacter.prototype, 'secondaryWeapon', {
get: function() {
return this.parent.items.find(x => x.type === 'weapon' && x.system.equipped && x.system.secondary);
},
configurable: true
});
}
});