[Fix] Improve Class-Subclass Linkage (#1846)
Some checks failed
Project CI / build (24.x) (push) Has been cancelled

* Initial thoughts

* .

* Fixed linting

* Continue work on updating identifier

* Change to uuid approach

* Localization and minor fix

* Fixed CompendiumBrowser Class filter for Subclass view

* Fixed the class name display in the subclass view

* Improved missing class visual for subclass

* Fixed character creation

* Rerender class sheets when subclass link is changed

* Use compendium source over actual uuid in search

---------

Co-authored-by: Carlos Fernandez <cfern1990@gmail.com>
This commit is contained in:
WBHarry 2026-05-05 22:15:21 +02:00 committed by GitHub
parent fb5e3672dc
commit b7bc452bf5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 167 additions and 84 deletions

View file

@ -2442,6 +2442,7 @@
"single": "Miss", "single": "Miss",
"plural": "Miss" "plural": "Miss"
}, },
"missingX": "Missing {x}",
"maxWithThing": "Max {thing}", "maxWithThing": "Max {thing}",
"missingDragDropThing": "Drop {thing} here", "missingDragDropThing": "Drop {thing} here",
"multiclass": "Multiclass", "multiclass": "Multiclass",
@ -2532,6 +2533,9 @@
"recovery": { "label": "Recovery" }, "recovery": { "label": "Recovery" },
"type": { "label": "Type" }, "type": { "label": "Type" },
"value": { "label": "Value" } "value": { "label": "Value" }
},
"identifier": {
"label": "Identifier"
} }
}, },
"Ancestry": { "Ancestry": {
@ -3219,7 +3223,6 @@
"subclassesAlreadyPresent": "You already have a class and multiclass subclass", "subclassesAlreadyPresent": "You already have a class and multiclass subclass",
"noDiceSystem": "Your selected dice {system} does not have a {faces} dice", "noDiceSystem": "Your selected dice {system} does not have a {faces} dice",
"gmMenuRefresh": "You refreshed all actions and resources {types}", "gmMenuRefresh": "You refreshed all actions and resources {types}",
"subclassAlreadyLinked": "{name} is already a subclass in the class {class}. Remove it from there if you want it to be a subclass to this class.",
"gmRequired": "This action requires an online GM", "gmRequired": "This action requires an online GM",
"gmOnly": "This can only be accessed by the GM", "gmOnly": "This can only be accessed by the GM",
"noActorOwnership": "You do not have permissions for this character", "noActorOwnership": "You do not have permissions for this character",

View file

@ -439,10 +439,13 @@ export default class DhCharacterCreation extends HandlebarsApplicationMixin(Appl
'system.domain': { key: 'system.domain', value: this.setup.class?.system.domains ?? null } 'system.domain': { key: 'system.domain', value: this.setup.class?.system.domains ?? null }
}; };
if (type === 'subclasses') if (type === 'subclasses') {
const classItem = this.setup.class;
const uuid = classItem?._stats.compendiumSource ?? classItem?.uuid;
presets.filter = { presets.filter = {
'system.linkedClass.uuid': { key: 'system.linkedClass.uuid', value: this.setup.class?.uuid } 'system.linkedClass': { key: 'system.linkedClass', value: uuid }
}; };
}
if (equipment.includes(type)) if (equipment.includes(type))
presets.filter = { presets.filter = {
@ -610,7 +613,8 @@ export default class DhCharacterCreation extends HandlebarsApplicationMixin(Appl
[foundry.utils.randomID()]: {} [foundry.utils.randomID()]: {}
}; };
} else if (item.type === 'subclass' && event.target.closest('.subclass-card')) { } else if (item.type === 'subclass' && event.target.closest('.subclass-card')) {
if (this.setup.class.system.subclasses.every(subclass => subclass.uuid !== item.uuid)) { const classSubclasses = await this.setup.class.system.fetchSubclasses();
if (classSubclasses.every(subclass => subclass.uuid !== item.uuid)) {
ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.subclassNotInClass')); ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.subclassNotInClass'));
return; return;
} }

View file

@ -104,9 +104,10 @@ export default class ClassSheet extends DHBaseItemSheet {
} }
/**@inheritdoc */ /**@inheritdoc */
async _prepareContext(_options) { async _prepareContext(options) {
const context = await super._prepareContext(_options); const context = await super._prepareContext(options);
context.domains = this.document.system.domains; context.domains = this.document.system.domains;
context.subclasses = await this.document.system.fetchSubclasses();
return context; return context;
} }
@ -128,20 +129,8 @@ export default class ClassSheet extends DHBaseItemSheet {
const item = await fromUuid(data.uuid); const item = await fromUuid(data.uuid);
const itemType = data.type === 'ActiveEffect' ? data.type : item.type; const itemType = data.type === 'ActiveEffect' ? data.type : item.type;
const target = event.target.closest('fieldset.drop-section'); const target = event.target.closest('fieldset.drop-section');
if (itemType === 'subclass') {
if (item.system.linkedClass) { if (['feature', 'ActiveEffect'].includes(itemType)) {
return ui.notifications.warn(
game.i18n.format('DAGGERHEART.UI.Notifications.subclassAlreadyLinked', {
name: item.name,
class: this.document.name
})
);
}
await item.update({ 'system.linkedClass': this.document.uuid });
await this.document.update({
'system.subclasses': [...this.document.system.subclasses.map(x => x.uuid), item.uuid]
});
} else if (['feature', 'ActiveEffect'].includes(itemType)) {
super._onDrop(event); super._onDrop(event);
} else if (this.document.parent?.type !== 'character') { } else if (this.document.parent?.type !== 'character') {
if (itemType === 'weapon') { if (itemType === 'weapon') {
@ -200,12 +189,6 @@ export default class ClassSheet extends DHBaseItemSheet {
static async #removeItemFromCollection(_event, element) { static async #removeItemFromCollection(_event, element) {
const { uuid, target } = element.dataset; const { uuid, target } = element.dataset;
const prop = foundry.utils.getProperty(this.document.system, target); const prop = foundry.utils.getProperty(this.document.system, target);
if (target === 'subclasses') {
const subclass = await foundry.utils.fromUuid(uuid);
await subclass?.update({ 'system.linkedClass': null });
}
await this.document.update({ [`system.${target}`]: prop.filter(i => i && i.uuid !== uuid).map(x => x.uuid) }); await this.document.update({ [`system.${target}`]: prop.filter(i => i && i.uuid !== uuid).map(x => x.uuid) });
} }

View file

@ -40,4 +40,36 @@ export default class SubclassSheet extends DHBaseItemSheet {
get relatedDocs() { get relatedDocs() {
return this.document.system.features.map(x => x.item); return this.document.system.features.map(x => x.item);
} }
async _prepareContext(options) {
const context = await super._prepareContext(options);
if (this.document.system.linkedClass) {
const classData = await fromUuid(this.document.system.linkedClass);
context.class = classData ?? {
name: _loc('DAGGERHEART.GENERAL.missingX', { x: _loc('TYPES.Item.class') }),
missing: true
};
}
return context;
}
async _onDrop(event) {
event.stopPropagation();
const data = TextEditor.getDragEventData(event);
const item = await fromUuid(data.uuid);
const itemType = data.type === 'ActiveEffect' ? data.type : item.type;
if (itemType === 'class') {
const uuid = item._stats.compendiumSource ?? item.uuid;
if (this.document.system.linkedClass !== uuid) {
await this.document.update({ 'system.linkedClass': uuid });
// Re-render all class sheets for instant feedback
for (const app of foundry.applications.instances.values()) {
if (app.document?.type === 'class') app.render();
}
}
return;
}
return super._onDrop(event);
}
} }

View file

@ -277,7 +277,7 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
(await foundry.applications.ux.TextEditor.implementation.enrichHTML(item.description)); (await foundry.applications.ux.TextEditor.implementation.enrichHTML(item.description));
} }
this.fieldFilter = this._createFieldFilter(); this.fieldFilter = await this._createFieldFilter();
if (this.presets?.filter) { if (this.presets?.filter) {
Object.entries(this.presets.filter).forEach(([k, v]) => { Object.entries(this.presets.filter).forEach(([k, v]) => {
@ -355,12 +355,12 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
); );
} }
_createFieldFilter() { async _createFieldFilter() {
const filters = ItemBrowser.getFolderConfig(this.selectedMenu.data, 'filters'); const filters = ItemBrowser.getFolderConfig(this.selectedMenu.data, 'filters');
filters.forEach(f => { for (const f of filters) {
if (typeof f.field === 'string') f.field = foundry.utils.getProperty(game, f.field); if (typeof f.field === 'string') f.field = foundry.utils.getProperty(game, f.field);
else if (typeof f.choices === 'function') { else if (typeof f.choices === 'function') {
f.choices = f.choices(this.items); f.choices = await f.choices(this.items);
} }
// Clear field label so template uses our custom label parameter // Clear field label so template uses our custom label parameter
@ -370,7 +370,8 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
f.name ??= f.key; f.name ??= f.key;
f.value = this.presets?.filter?.[f.name]?.value ?? null; f.value = this.presets?.filter?.[f.name]?.value ?? null;
}); }
return filters; return filters;
} }

View file

@ -383,7 +383,8 @@ export const typeConfig = {
{ {
key: 'system.linkedClass', key: 'system.linkedClass',
label: 'TYPES.Item.class', label: 'TYPES.Item.class',
format: linkedClass => linkedClass?.name ?? 'DAGGERHEART.UI.ItemBrowser.missing' format: linkedClass =>
foundry.utils.fromUuidSync(linkedClass)?.name ?? 'DAGGERHEART.UI.ItemBrowser.missing'
}, },
{ {
key: 'system.spellcastingTrait', key: 'system.spellcastingTrait',
@ -393,15 +394,18 @@ export const typeConfig = {
], ],
filters: [ filters: [
{ {
key: 'system.linkedClass.uuid', key: 'system.linkedClass',
label: 'TYPES.Item.class', label: 'TYPES.Item.class',
choices: items => { choices: async items => {
const list = items const list = [];
.filter(item => item.system.linkedClass) for (const item of items.filter(item => item.system.linkedClass)) {
.map(item => ({ const linkedClass = await foundry.utils.fromUuid(item.system.linkedClass);
value: item.system.linkedClass.uuid, list.push({
label: item.system.linkedClass.name value: linkedClass.uuid,
})); label: linkedClass.name
});
}
return list.reduce((a, c) => { return list.reduce((a, c) => {
if (!a.find(i => i.value === c.value)) a.push(c); if (!a.find(i => i.value === c.value)) a.push(c);
return a; return a;

View file

@ -200,7 +200,7 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel {
const features = []; const features = [];
for (let f of this.features) { for (let f of this.features) {
const fBase = f.item ?? f; const fBase = f.item ?? f;
const feature = fBase.system ? fBase : await foundry.utils.fromUuid(fBase.uuid); const feature = fBase.pack ? await foundry.utils.fromUuid(fBase.uuid) : fBase;
features.push( features.push(
foundry.utils.mergeObject( foundry.utils.mergeObject(
feature.toObject(), feature.toObject(),

View file

@ -30,7 +30,6 @@ export default class DHClass extends BaseDataItem {
}), }),
evasion: new fields.NumberField({ initial: 0, integer: true, label: 'DAGGERHEART.GENERAL.evasion' }), evasion: new fields.NumberField({ initial: 0, integer: true, label: 'DAGGERHEART.GENERAL.evasion' }),
features: new ItemLinkFields(), features: new ItemLinkFields(),
subclasses: new ForeignDocumentUUIDArrayField({ type: 'Item', required: false }),
inventory: new fields.SchemaField({ inventory: new fields.SchemaField({
take: new ForeignDocumentUUIDArrayField({ type: 'Item', required: false }), take: new ForeignDocumentUUIDArrayField({ type: 'Item', required: false }),
choiceA: new ForeignDocumentUUIDArrayField({ type: 'Item', required: false }), choiceA: new ForeignDocumentUUIDArrayField({ type: 'Item', required: false }),
@ -70,6 +69,24 @@ export default class DHClass extends BaseDataItem {
return this.features.filter(x => x.type === CONFIG.DH.ITEM.featureSubTypes.class).map(x => x.item); return this.features.filter(x => x.type === CONFIG.DH.ITEM.featureSubTypes.class).map(x => x.item);
} }
async fetchSubclasses() {
const uuids = [this.parent.uuid, this.parent._stats?.compendiumSource].filter(u => !!u);
const subclasses = game.items.filter(x => x.type === 'subclass' && uuids.includes(x.system.linkedClass));
for (const pack of game.packs) {
const indexes = await pack.getIndex({ fields: ['system.linkedClass'] });
for (const index of indexes) {
if (index.type !== 'subclass') continue;
if (!uuids.includes(index.system?.linkedClass)) continue;
if (subclasses.find(x => x.uuid === index.uuid)) continue;
const subclass = await foundry.utils.fromUuid(index.uuid);
subclasses.push(subclass);
}
}
return subclasses;
}
async _preCreate(data, options, user) { async _preCreate(data, options, user) {
if (this.actor?.type === 'character') { if (this.actor?.type === 'character') {
const levelupAuto = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).levelupAuto; const levelupAuto = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).levelupAuto;

View file

@ -28,7 +28,7 @@ export default class DHSubclass extends BaseDataItem {
features: new ItemLinkFields(), features: new ItemLinkFields(),
featureState: new fields.NumberField({ required: true, initial: 1, min: 1 }), featureState: new fields.NumberField({ required: true, initial: 1, min: 1 }),
isMulticlass: new fields.BooleanField({ initial: false }), isMulticlass: new fields.BooleanField({ initial: false }),
linkedClass: new ForeignDocumentUUIDField({ type: 'Item', nullable: true, initial: null }) linkedClass: new fields.DocumentUUIDField({ type: 'Item', nullable: true, initial: null })
}; };
} }
@ -83,7 +83,8 @@ export default class DHSubclass extends BaseDataItem {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.missingClass')); ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.missingClass'));
return false; return false;
} }
if (actorClass.system.subclasses.every(x => x.uuid !== dataUuid)) {
if ((await actorClass.system.fetchSubclasses()).every(x => x.uuid !== dataUuid)) {
ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.subclassNotInClass')); ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.subclassNotInClass'));
return false; return false;
} }

View file

@ -861,3 +861,11 @@ export function createShallowProxy(obj) {
} }
}); });
} }
export function camelize(str) {
return str
.replace(/(?:^\w|[A-Z]|\b\w)/g, (part, index) => {
return index === 0 ? part.toLowerCase() : part.toUpperCase();
})
.replace(/\s+/g, '');
}

View file

@ -19,28 +19,36 @@
&:last-child { &:last-child {
margin-bottom: 0px; margin-bottom: 0px;
} }
.feature-line { }
display: grid; .feature-line {
display: grid;
align-items: center;
grid-template-columns: 1fr 4fr 1fr;
h4 {
font-weight: lighter;
color: light-dark(@dark, @beige);
}
.image {
height: 40px;
width: 40px;
object-fit: cover;
border-radius: 6px;
border: none;
}
.image-icon {
font-size: 26px;
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center; align-items: center;
grid-template-columns: 1fr 4fr 1fr; }
h4 { .controls {
font-weight: lighter; display: flex;
color: light-dark(@dark, @beige); justify-content: center;
} gap: 10px;
.image { a {
height: 40px; text-shadow: none;
width: 40px;
object-fit: cover;
border-radius: 6px;
border: none;
}
.controls {
display: flex;
justify-content: center;
gap: 10px;
a {
text-shadow: none;
}
} }
} }
} }

View file

@ -10,4 +10,8 @@
font-family: @font-body; font-family: @font-body;
color: light-dark(@chat-blue-bg, @beige-50); color: light-dark(@chat-blue-bg, @beige-50);
} }
button.plain.inline-control {
flex: 0 0 auto;
}
} }

View file

@ -27,10 +27,7 @@
<fieldset> <fieldset>
<legend>{{localize "TYPES.Item.subclass"}}</legend> <legend>{{localize "TYPES.Item.subclass"}}</legend>
<div class="feature-list"> <div class="feature-list">
{{#unless source.system.subclasses}} {{#each subclasses as |subclass index|}}
<div class="drag-area">{{localize "DAGGERHEART.GENERAL.missingDragDropThing" thing=(localize "DAGGERHEART.GENERAL.subclasses")}}</div>
{{/unless}}
{{#each source.system.subclasses as |subclass index|}}
<li class='feature-item'> <li class='feature-item'>
<div class='feature-line'> <div class='feature-line'>
<img class='image' src='{{subclass.img}}' /> <img class='image' src='{{subclass.img}}' />
@ -44,16 +41,7 @@
data-item-uuid={{subclass.uuid}} data-item-uuid={{subclass.uuid}}
data-tooltip='{{localize "DAGGERHEART.UI.Tooltip.openItemWorld"}}' data-tooltip='{{localize "DAGGERHEART.UI.Tooltip.openItemWorld"}}'
> >
<i class="fa-solid fa-globe"></i> <i class="fa-solid fa-globe" inert></i>
</a>
<a
class='effect-control'
data-action='removeItemFromCollection'
data-target="subclasses"
data-uuid="{{subclass.uuid}}"
data-tooltip='{{localize "CONTROLS.CommonDelete"}}'
>
<i class='fas fa-trash'></i>
</a> </a>
</div> </div>
</div> </div>

View file

@ -3,7 +3,6 @@
<div class='item-info'> <div class='item-info'>
<h1 class='item-name'><input type='text' name='name' value='{{source.name}}' /></h1> <h1 class='item-name'><input type='text' name='name' value='{{source.name}}' /></h1>
<div class='item-description'> <div class='item-description'>
<h3>{{localize 'TYPES.Item.class'}}</h3>
<h3 class="form-fields domain-section"> <h3 class="form-fields domain-section">
<span>{{localize "DAGGERHEART.GENERAL.Domain.plural"}}</span> <span>{{localize "DAGGERHEART.GENERAL.Domain.plural"}}</span>
<input class="domain-input" value="{{domains}}" /> <input class="domain-input" value="{{domains}}" />

View file

@ -1,8 +1,10 @@
<div class="item-description-outer-container"> <div class="item-description-outer-container">
<div class="item-description-container"> {{#if spellcastTrait}}
<h4>{{localize "DAGGERHEART.ITEMS.Subclass.spellcastTrait"}}</h4> <div class="item-description-container">
<span>{{spellcastTrait}}</span> <h4>{{localize "DAGGERHEART.ITEMS.Subclass.spellcastTrait"}}</h4>
</div> <span>{{spellcastTrait}}</span>
</div>
{{/if}}
<div class="item-description-container"> <div class="item-description-container">
<h4>{{localize "DAGGERHEART.ITEMS.Subclass.foundationFeatures"}}</h4> <h4>{{localize "DAGGERHEART.ITEMS.Subclass.foundationFeatures"}}</h4>
{{#each foundationFeatures as | feature |}} {{#each foundationFeatures as | feature |}}

View file

@ -3,6 +3,35 @@
data-tab='{{tabs.features.id}}' data-tab='{{tabs.features.id}}'
data-group='{{tabs.features.group}}' data-group='{{tabs.features.group}}'
> >
<fieldset>
<legend>{{localize "TYPES.Item.class"}}</legend>
{{#if class}}
<div class="feature-list">
<li class="feature-line">
{{#if class.missing}}
<i class="fa-solid fa-link-slash hint image-icon" inert></i>
<span class="hint">{{class.name}}</span>
{{else}}
<img class="image" src="{{class.img}}" />
<span>{{class.name}}</span>
<div class='controls'>
<a
class='effect-control'
data-action='editDoc'
data-item-uuid={{class.uuid}}
data-tooltip='{{localize "DAGGERHEART.UI.Tooltip.openItemWorld"}}'
>
<i class="fa-solid fa-globe" inert></i>
</a>
</div>
{{/if}}
</li>
</div>
{{else}}
<div class="drag-area">{{localize "DAGGERHEART.GENERAL.missingDragDropThing" thing=(localize "TYPES.Item.class")}}</div>
{{/if}}
</fieldset>
<fieldset class="drop-section" data-type="foundation"> <fieldset class="drop-section" data-type="foundation">
<legend> <legend>
{{localize "DAGGERHEART.GENERAL.Tabs.foundation"}} {{localize "DAGGERHEART.GENERAL.Tabs.foundation"}}