[Feature] Item Resource Support (#328)

* Initial

* Resource setup finished

* Fixed so that costs can be used

* Corrected standard resources

* Actions can only use item resources from their parent item

* Fixed up dice

* Fixed resource dice positioning

* Fixed parsing of resource.max

* Fixed styling on settings tab

* Added manual input for Dice Resources

* Lightmode fixes

* Fixed Feature spellcasting modifier

* Bugfix for item input to resourceDiceDialog

* Item fix for TokenInput

* PR Fixes
This commit is contained in:
WBHarry 2025-07-14 01:12:32 +02:00 committed by GitHub
parent eefa116d9a
commit 4be3e6179c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 972 additions and 329 deletions

View file

@ -107,7 +107,7 @@ export class DHDamageData extends foundry.abstract.DataModel {
}),
{
label: 'Type',
initial: 'physical',
initial: 'physical'
}
),
resultBased: new fields.BooleanField({

View file

@ -1,4 +1,4 @@
import { DHActionDiceData, DHActionRollData, DHDamageData, DHDamageField } from './actionDice.mjs';
import { DHActionDiceData, DHActionRollData, DHDamageField } from './actionDice.mjs';
import DhpActor from '../../documents/actor.mjs';
import D20RollDialog from '../../applications/dialogs/d20RollDialog.mjs';
@ -35,12 +35,12 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
}),
cost: new fields.ArrayField(
new fields.SchemaField({
type: new fields.StringField({
choices: CONFIG.DH.GENERAL.abilityCosts,
key: new fields.StringField({
nullable: false,
required: true,
initial: 'hope'
}),
keyIsID: new fields.BooleanField(),
value: new fields.NumberField({ nullable: true, initial: 1 }),
scalable: new fields.BooleanField({ initial: false }),
step: new fields.NumberField({ nullable: true, initial: null })
@ -181,7 +181,7 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
// Add Roll results to RollDatas
actorData.result = data.roll?.total ?? 1;
actorData.scale = data.costs?.length // Right now only return the first scalable cost.
? (data.costs.find(c => c.scalable)?.total ?? 1)
: 1;
@ -204,7 +204,7 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
// Prepare Costs
const costsConfig = this.prepareCost();
if (isFastForward && !this.hasCost(costsConfig))
if (isFastForward && !(await this.hasCost(costsConfig)))
return ui.notifications.warn("You don't have the resources to use that action.");
// Prepare Uses
@ -278,7 +278,7 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
prepareCost() {
const costs = this.cost?.length ? foundry.utils.deepClone(this.cost) : [];
return costs;
return this.calcCosts(costs);
}
prepareUse() {
@ -327,11 +327,26 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
}
async consume(config) {
const usefulResources = foundry.utils.deepClone(this.actor.system.resources);
for (var cost of config.costs) {
if (cost.keyIsID) {
usefulResources[cost.key] = {
value: cost.value,
target: this.parent.parent,
keyIsID: true
};
}
}
const resources = config.costs
.filter(c => c.enabled !== false)
.map(c => {
const resource = this.actor.system.resources[c.type];
return { type: c.type, value: (c.total ?? c.value) * (resource.isReversed ? 1 : -1) };
const resource = usefulResources[c.key];
return {
key: c.key,
value: (c.total ?? c.value) * (resource.isReversed ? 1 : -1),
target: resource.target,
keyIsID: resource.keyIsID
};
});
await this.actor.modifyResource(resources);
@ -372,9 +387,27 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
});
}
hasCost(costs) {
async getResources(costs) {
const actorResources = this.actor.system.resources;
const itemResources = {};
for (var itemResource of costs) {
if (itemResource.keyIsID) {
itemResources[itemResource.key] = {
value: this.parent.resource.value ?? 0
};
}
}
return {
...actorResources,
...itemResources
};
}
/* COST */
async hasCost(costs) {
const realCosts = this.getRealCosts(costs),
hasFearCost = realCosts.findIndex(c => c.type === 'fear');
hasFearCost = realCosts.findIndex(c => c.key === 'fear');
if (hasFearCost > -1) {
const fearCost = realCosts.splice(hasFearCost, 1)[0];
if (
@ -385,16 +418,15 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
}
/* isReversed is a sign that the resource is inverted, IE it counts upwards instead of down */
const resources = this.actor.system.resources;
const resources = await this.getResources(realCosts);
return realCosts.reduce(
(a, c) =>
a && resources[c.type].isReversed
? resources[c.type].value + (c.total ?? c.value) <= resources[c.type].max
: resources[c.type]?.value >= (c.total ?? c.value),
a && resources[c.key].isReversed
? resources[c.key].value + (c.total ?? c.value) <= resources[c.key].max
: resources[c.key]?.value >= (c.total ?? c.value),
true
);
}
/* COST */
/* USES */
calcUses(uses) {
@ -409,7 +441,6 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
if (!uses) return true;
return (uses.hasOwnProperty('enabled') && !uses.enabled) || uses.value + 1 <= uses.max;
}
/* USES */
/* TARGET */
isTargetFriendly(target) {

View file

@ -11,8 +11,8 @@ export default class DHDamageAction extends DHBaseAction {
async rollDamage(event, data) {
let formula = this.damage.parts.map(p => this.getFormulaValue(p, data).getFormula(this.actor)).join(' + '),
damageTypes = [...new Set(this.damage.parts.reduce((a,c) => a.concat([...c.type]), []))];
damageTypes = [...new Set(this.damage.parts.reduce((a, c) => a.concat([...c.type]), []))];
damageTypes = !damageTypes.length ? ['physical'] : damageTypes;
if (!formula || formula == '') return;
@ -36,7 +36,7 @@ export default class DHDamageAction extends DHBaseAction {
config.source.message = data._id;
config.directDamage = false;
}
roll = CONFIG.Dice.daggerheart.DamageRoll.build(config);
}
}

View file

@ -1,4 +1,4 @@
import DHBaseActorSettings from "../../applications/sheets/api/actor-setting.mjs";
import DHBaseActorSettings from '../../applications/sheets/api/actor-setting.mjs';
const resistanceField = () =>
new foundry.data.fields.SchemaField({
@ -37,13 +37,12 @@ export default class BaseDataActor extends foundry.abstract.TypeDataModel {
const fields = foundry.data.fields;
const schema = {};
if(this.metadata.isNPC)
schema.description = new fields.HTMLField({ required: true, nullable: true });
if(this.metadata.hasResistances)
if (this.metadata.isNPC) schema.description = new fields.HTMLField({ required: true, nullable: true });
if (this.metadata.hasResistances)
schema.resistance = new fields.SchemaField({
physical: resistanceField(),
magical: resistanceField()
})
});
return schema;
}

View file

@ -39,9 +39,7 @@ export default class DhCharacter extends BaseDataActor {
resources: new fields.SchemaField({
hitPoints: resourceField(0, true),
stress: resourceField(6, true),
hope: resourceField(6),
tokens: new fields.ObjectField(),
dice: new fields.ObjectField()
hope: resourceField(6)
}),
traits: new fields.SchemaField({
agility: attributeField(),
@ -292,9 +290,7 @@ export default class DhCharacter extends BaseDataActor {
}
get deathMoveViable() {
return (
this.resources.hitPoints.max > 0 && this.resources.hitPoints.value >= this.resources.hitPoints.max
);
return this.resources.hitPoints.max > 0 && this.resources.hitPoints.value >= this.resources.hitPoints.max;
}
get armorApplicableDamageTypes() {

View file

@ -10,7 +10,6 @@ export default class DHArmor extends AttachableItem {
label: 'TYPES.Item.armor',
type: 'armor',
hasDescription: true,
isQuantifiable: true,
isInventoryItem: true
});
}

View file

@ -5,7 +5,7 @@ export default class AttachableItem extends BaseDataItem {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
attached: new fields.ArrayField(new fields.DocumentUUIDField({ type: "Item", nullable: true }))
attached: new fields.ArrayField(new fields.DocumentUUIDField({ type: 'Item', nullable: true }))
};
}
@ -90,7 +90,10 @@ export default class AttachableItem extends BaseDataItem {
});
if (effectsToRemove.length > 0) {
await actor.deleteEmbeddedDocuments('ActiveEffect', effectsToRemove.map(e => e.id));
await actor.deleteEmbeddedDocuments(
'ActiveEffect',
effectsToRemove.map(e => e.id)
);
}
}
@ -140,13 +143,18 @@ export default class AttachableItem extends BaseDataItem {
const parentUuidProperty = `${parentType}Uuid`;
const effectsToRemove = actor.effects.filter(effect => {
const attachmentSource = effect.getFlag(CONFIG.DH.id, CONFIG.DH.FLAGS.itemAttachmentSource);
return attachmentSource &&
return (
attachmentSource &&
attachmentSource[parentUuidProperty] === this.parent.uuid &&
attachmentSource.itemUuid === attachedUuid;
attachmentSource.itemUuid === attachedUuid
);
});
if (effectsToRemove.length > 0) {
await actor.deleteEmbeddedDocuments('ActiveEffect', effectsToRemove.map(e => e.id));
await actor.deleteEmbeddedDocuments(
'ActiveEffect',
effectsToRemove.map(e => e.id)
);
}
}
}
}

View file

@ -11,12 +11,15 @@
const fields = foundry.data.fields;
export default class BaseDataItem extends foundry.abstract.TypeDataModel {
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ITEMS'];
/** @returns {ItemDataModelMetadata}*/
static get metadata() {
return {
label: 'Base Item',
type: 'base',
hasDescription: false,
hasResource: false,
isQuantifiable: false,
isInventoryItem: false
};
@ -33,6 +36,33 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel {
if (this.metadata.hasDescription) schema.description = new fields.HTMLField({ required: true, nullable: true });
if (this.metadata.hasResource) {
schema.resource = new fields.SchemaField(
{
type: new fields.StringField({
choices: CONFIG.DH.ITEM.itemResourceTypes,
initial: CONFIG.DH.ITEM.itemResourceTypes.simple
}),
value: new fields.NumberField({ integer: true, min: 0, initial: 0 }),
max: new fields.StringField({ nullable: true, initial: null }),
icon: new fields.StringField(),
recovery: new fields.StringField({
choices: CONFIG.DH.GENERAL.refreshTypes,
initial: null,
nullable: true
}),
diceStates: new fields.TypedObjectField(
new fields.SchemaField({
value: new fields.NumberField({ integer: true, nullable: true, initial: null }),
used: new fields.BooleanField({ initial: false })
})
),
dieFaces: new fields.StringField({ initial: '4' })
},
{ nullable: true, initial: null }
);
}
if (this.metadata.isQuantifiable)
schema.quantity = new fields.NumberField({ integer: true, initial: 1, min: 0, required: true });
@ -62,28 +92,27 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel {
return data;
}
/**@inheritdoc */
async _preCreate(data, options, user) {
// Skip if no initial action is required or actions already exist
if (!this.metadata.hasInitialAction || !foundry.utils.isEmpty(this.actions)) return;
if (this.metadata.hasInitialAction && foundry.utils.isEmpty(this.actions)) {
const metadataType = this.metadata.type;
const actionType = { weapon: 'attack' }[metadataType];
const ActionClass = game.system.api.models.actions.actionsTypes[actionType];
const metadataType = this.metadata.type;
const actionType = { weapon: 'attack' }[metadataType];
const ActionClass = game.system.api.models.actions.actionsTypes[actionType];
const action = new ActionClass(
{
_id: foundry.utils.randomID(),
type: actionType,
name: game.i18n.localize(CONFIG.DH.ACTIONS.actionTypes[actionType].name),
...ActionClass.getSourceConfig(this.parent)
},
{
parent: this.parent
}
);
const action = new ActionClass(
{
_id: foundry.utils.randomID(),
type: actionType,
name: game.i18n.localize(CONFIG.DH.ACTIONS.actionTypes[actionType].name),
...ActionClass.getSourceConfig(this.parent)
},
{
parent: this.parent
}
);
this.updateSource({ actions: [action] });
this.updateSource({ actions: [action] });
}
}
_onCreate(data) {
@ -95,7 +124,7 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel {
...feature,
system: {
...feature.system,
type: this.parent.type,
originItemType: this.parent.type,
originId: data._id,
identifier: feature.identifier
}

View file

@ -7,7 +7,8 @@ export default class DHDomainCard extends BaseDataItem {
return foundry.utils.mergeObject(super.metadata, {
label: 'TYPES.Item.domainCard',
type: 'domainCard',
hasDescription: true
hasDescription: true,
hasResource: true
});
}

View file

@ -7,7 +7,8 @@ export default class DHFeature extends BaseDataItem {
return foundry.utils.mergeObject(super.metadata, {
label: 'TYPES.Item.feature',
type: 'feature',
hasDescription: true
hasDescription: true,
hasResource: true
});
}
@ -16,10 +17,32 @@ export default class DHFeature extends BaseDataItem {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
type: new fields.StringField({ choices: CONFIG.DH.ITEM.featureTypes, nullable: true, initial: null }),
originItemType: new fields.StringField({
choices: CONFIG.DH.ITEM.featureTypes,
nullable: true,
initial: null
}),
originId: new fields.StringField({ nullable: true, initial: null }),
identifier: new fields.StringField(),
actions: new fields.ArrayField(new ActionField())
};
}
get spellcastingModifier() {
let traitValue = 0;
if (this.actor && this.originId && ['class', 'subclass'].includes(this.originItemType)) {
if (this.originItemType === 'subclass') {
traitValue =
this.actor.system.traits[this.actor.items.get(this.originId).system.spellcastingTrait]?.value ?? 0;
} else {
const subclass =
this.actor.system.multiclass.value?.id === this.originId
? this.actor.system.multiclass.subclass
: this.actor.system.class.subclass;
traitValue = this.actor.system.traits[subclass.system.spellcastingTrait]?.value ?? 0;
}
}
return traitValue;
}
}

View file

@ -9,7 +9,6 @@ export default class DHWeapon extends AttachableItem {
label: 'TYPES.Item.weapon',
type: 'weapon',
hasDescription: true,
isQuantifiable: true,
isInventoryItem: true
// hasInitialAction: true
});
@ -37,7 +36,7 @@ export default class DHWeapon extends AttachableItem {
actionIds: new fields.ArrayField(new fields.StringField({ required: true }))
})
),
attack: new ActionField({
attack: new ActionField({
initial: {
name: 'Attack',
img: 'icons/skills/melee/blood-slash-foam-red.webp',