[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

@ -7,3 +7,4 @@ export { default as DamageSelectionDialog } from './damageSelectionDialog.mjs';
export { default as DeathMove } from './deathMove.mjs';
export { default as Downtime } from './downtime.mjs';
export { default as OwnershipSelection } from './ownershipSelection.mjs';
export { default as ResourceDiceDialog } from './resourceDiceDialog.mjs';

View file

@ -66,7 +66,12 @@ export default class D20RollDialog extends HandlebarsApplicationMixin(Applicatio
context.canRoll = true;
if (this.config.costs?.length) {
const updatedCosts = this.action.calcCosts(this.config.costs);
context.costs = updatedCosts;
context.costs = updatedCosts.map(x => ({
...x,
label: x.keyIsID
? this.action.parent.parent.name
: game.i18n.localize(CONFIG.DH.GENERAL.abilityCosts[x.key].label)
}));
context.canRoll = this.action.hasCost(updatedCosts);
this.config.data.scale = this.config.costs[0].total;
}
@ -74,7 +79,7 @@ export default class D20RollDialog extends HandlebarsApplicationMixin(Applicatio
context.uses = this.action.calcUses(this.config.uses);
context.canRoll = context.canRoll && this.action.hasUses(context.uses);
}
if(this.roll) {
if (this.roll) {
context.roll = this.roll;
context.rollType = this.roll?.constructor.name;
context.experiences = Object.keys(this.config.data.experiences).map(id => ({

View file

@ -0,0 +1,99 @@
import { itemAbleRollParse } from '../../helpers/utils.mjs';
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
export default class ResourceDiceDialog extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(item, actor, options = {}) {
super(options);
this.item = item;
this.actor = actor;
this.diceStates = foundry.utils.deepClone(item.system.resource.diceStates);
}
static DEFAULT_OPTIONS = {
tag: 'form',
classes: ['daggerheart', 'dialog', 'dh-style', 'views', 'resource-dice'],
window: {
icon: 'fa-solid fa-dice'
},
actions: {
rerollDice: this.rerollDice,
save: this.save
},
form: {
handler: this.updateResourceDice,
submitOnChange: true,
submitOnClose: false
}
};
/** @override */
static PARTS = {
resourceDice: {
id: 'resourceDice',
template: 'systems/daggerheart/templates/dialogs/dice-roll/resourceDice.hbs'
}
};
get title() {
return game.i18n.format('DAGGERHEART.APPLICATIONS.ResourceDice.title', { name: this.item.name });
}
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
context.item = this.item;
context.actor = this.actor;
context.diceStates = this.diceStates;
return context;
}
static async updateResourceDice(event, _, formData) {
const { diceStates } = foundry.utils.expandObject(formData.object);
this.diceStates = Object.keys(diceStates).reduce((acc, key) => {
const resourceState = this.item.system.resource.diceStates[key];
acc[key] = { ...diceStates[key], used: Boolean(resourceState?.used) };
return acc;
}, {});
this.render();
}
static async save() {
this.rollValues = Object.values(this.diceStates);
this.close();
}
static async rerollDice() {
const max = itemAbleRollParse(this.item.system.resource.max, this.actor, this.item);
const diceFormula = `${max}d${this.item.system.resource.dieFaces}`;
const roll = await new Roll(diceFormula).evaluate();
if (game.modules.get('dice-so-nice')?.active) await game.dice3d.showForRoll(roll, game.user, true);
this.rollValues = roll.terms[0].results.map(x => ({ value: x.result, used: false }));
this.resetUsed = true;
const cls = getDocumentClass('ChatMessage');
const msg = new cls({
user: game.user.id,
content: await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/ui/chat/resource-roll.hbs',
{
user: this.actor.name,
name: this.item.name
}
)
});
cls.create(msg.toObject());
this.close();
}
static async create(item, actor, options = {}) {
return new Promise(resolve => {
const app = new this(item, actor, options);
app.addEventListener('close', () => resolve(app.rollValues), { once: true });
app.render({ force: true });
});
}
}

View file

@ -107,6 +107,7 @@ export default class DHActionConfig extends DaggerheartSheet(ApplicationV2) {
context.hasBaseDamage = !!this.action.parent.attack;
context.getRealIndex = this.getRealIndex.bind(this);
context.getEffectDetails = this.getEffectDetails.bind(this);
context.costOptions = this.getCostOptions();
context.disableOption = this.disableOption.bind(this);
context.isNPC = this.action.actor && this.action.actor.type !== 'character';
context.hasRoll = this.action.hasRoll;
@ -125,8 +126,21 @@ export default class DHActionConfig extends DaggerheartSheet(ApplicationV2) {
this.render(true);
}
disableOption(index, options, choices) {
const filtered = foundry.utils.deepClone(options);
getCostOptions() {
const options = foundry.utils.deepClone(CONFIG.DH.GENERAL.abilityCosts);
const resource = this.action.parent.resource;
if (resource) {
options[this.action.parent.parent.id] = {
label: this.action.parent.parent.name,
group: 'TYPES.Actor.character'
};
}
return options;
}
disableOption(index, costOptions, choices) {
const filtered = foundry.utils.deepClone(costOptions);
Object.keys(filtered).forEach(o => {
if (choices.find((c, idx) => c.type === o && index !== idx)) filtered[o].disabled = true;
});
@ -142,11 +156,19 @@ export default class DHActionConfig extends DaggerheartSheet(ApplicationV2) {
return this.action.item.effects.get(id);
}
_prepareSubmitData(event, formData) {
_prepareSubmitData(_event, formData) {
const submitData = foundry.utils.expandObject(formData.object);
for (const keyPath of this.constructor.CLEAN_ARRAYS) {
const data = foundry.utils.getProperty(submitData, keyPath);
if (data) foundry.utils.setProperty(submitData, keyPath, Object.values(data));
const dataValues = data ? Object.values(data) : [];
if (keyPath === 'cost') {
for (var value of dataValues) {
const item = this.action.parent.parent.id === value.key;
value.keyIsID = Boolean(item);
}
}
if (data) foundry.utils.setProperty(submitData, keyPath, dataValues);
}
return submitData;
}

View file

@ -4,6 +4,7 @@ import { abilities } from '../../../config/actorConfig.mjs';
import DhCharacterlevelUp from '../../levelup/characterLevelup.mjs';
import DhCharacterCreation from '../../characterCreation/characterCreation.mjs';
import FilterMenu from '../../ux/filter-menu.mjs';
import { itemAbleRollParse } from '../../../helpers/utils.mjs';
/**@typedef {import('@client/applications/_types.mjs').ApplicationClickAction} ApplicationClickAction */
@ -25,6 +26,8 @@ export default class CharacterSheet extends DHBaseActorSheet {
toggleEquipItem: CharacterSheet.#toggleEquipItem,
useItem: this.useItem, //TODO Fix this
useAction: this.useAction,
toggleResourceDice: this.toggleResourceDice,
handleResourceDice: this.handleResourceDice,
toChat: this.toChat
},
window: {
@ -91,6 +94,17 @@ export default class CharacterSheet extends DHBaseActorSheet {
}
};
_attachPartListeners(partId, htmlElement, options) {
super._attachPartListeners(partId, htmlElement, options);
htmlElement.querySelectorAll('.inventory-item-resource').forEach(element => {
element.addEventListener('change', this.updateItemResource.bind(this));
});
htmlElement.querySelectorAll('.inventory-item-quantity').forEach(element => {
element.addEventListener('change', this.updateItemQuantity.bind(this));
});
}
/** @inheritDoc */
async _onRender(context, options) {
await super._onRender(context, options);
@ -480,6 +494,27 @@ export default class CharacterSheet extends DHBaseActorSheet {
}
}
/* -------------------------------------------- */
/* Application Listener Actions */
/* -------------------------------------------- */
async updateItemResource(event) {
const item = this.getItem(event.currentTarget);
if (!item) return;
const max = item.system.resource.max ? itemAbleRollParse(item.system.resource.max, this.document, item) : null;
const value = max ? Math.min(Number(event.currentTarget.value), max) : event.currentTarget.value;
await item.update({ 'system.resource.value': value });
this.render();
}
async updateItemQuantity(event) {
const item = this.getItem(event.currentTarget);
if (!item) return;
await item.update({ 'system.quantity': event.currentTarget.value });
this.render();
}
/* -------------------------------------------- */
/* Application Clicks Actions */
/* -------------------------------------------- */
@ -640,6 +675,41 @@ export default class CharacterSheet extends DHBaseActorSheet {
action.use(event);
}
/**
* Toggle the used state of a resource dice.
* @type {ApplicationClickAction}
*/
static async toggleResourceDice(event) {
const target = event.target.closest('.item-resource');
const item = this.getItem(event);
if (!item) return;
const diceState = item.system.resource.diceStates[target.dataset.dice];
await item.update({
[`system.resource.diceStates.${target.dataset.dice}.used`]: diceState?.used ? !diceState.used : true
});
}
/**
* Handle the roll values of resource dice.
* @type {ApplicationClickAction}
*/
static async handleResourceDice(event) {
const item = this.getItem(event);
if (!item) return;
const rollValues = await game.system.api.applications.dialogs.ResourceDiceDialog.create(item, this.document);
if (!rollValues) return;
await item.update({
'system.resource.diceStates': rollValues.reduce((acc, state, index) => {
acc[index] = { value: state.value, used: state.used };
return acc;
}, {})
});
this.render();
}
/**
* Send item to Chat
* @type {ApplicationClickAction}
@ -672,14 +742,14 @@ export default class CharacterSheet extends DHBaseActorSheet {
async _onDragStart(event) {
const item = this.getItem(event);
const dragData = {
type: item.documentName,
uuid: item.uuid
};
event.dataTransfer.setData('text/plain', JSON.stringify(dragData));
super._onDragStart(event);
}
@ -687,7 +757,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
// Prevent event bubbling to avoid duplicate handling
event.preventDefault();
event.stopPropagation();
super._onDrop(event);
this._onDropItem(event, TextEditor.getDragEventData(event));
}

View file

@ -24,7 +24,9 @@ export default class DHBaseItemSheet extends DHApplicationMixin(ItemSheetV2) {
removeAction: DHBaseItemSheet.#removeAction,
addFeature: DHBaseItemSheet.#addFeature,
editFeature: DHBaseItemSheet.#editFeature,
removeFeature: DHBaseItemSheet.#removeFeature
removeFeature: DHBaseItemSheet.#removeFeature,
addResource: DHBaseItemSheet.#addResource,
removeResource: DHBaseItemSheet.#removeResource
},
dragDrop: [
{ dragSelector: null, dropSelector: '.tab.features .drop-section' },
@ -215,6 +217,26 @@ export default class DHBaseItemSheet extends DHApplicationMixin(ItemSheetV2) {
});
}
/**
* Add a resource to the item.
* @type {ApplicationClickAction}
*/
static async #addResource() {
await this.document.update({
'system.resource': { type: 'simple', value: 0 }
});
}
/**
* Remove the resource from the item.
* @type {ApplicationClickAction}
*/
static async #removeResource() {
await this.document.update({
'system.resource': null
});
}
/* -------------------------------------------- */
/* Application Drag/Drop */
/* -------------------------------------------- */

View file

@ -1,4 +1,3 @@
export default function ItemAttachmentSheet(Base) {
return class extends Base {
static DEFAULT_OPTIONS = {
@ -25,10 +24,7 @@ export default function ItemAttachmentSheet(Base) {
...super.TABS,
primary: {
...super.TABS?.primary,
tabs: [
...(super.TABS?.primary?.tabs || []),
{ id: 'attachments' }
],
tabs: [...(super.TABS?.primary?.tabs || []), { id: 'attachments' }],
initial: super.TABS?.primary?.initial || 'description',
labelPrefix: super.TABS?.primary?.labelPrefix || 'DAGGERHEART.GENERAL.Tabs'
}
@ -46,29 +42,28 @@ export default function ItemAttachmentSheet(Base) {
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
const attachmentsSection = event.target.closest('.attachments-section');
if (!attachmentsSection) return super._onDrop(event);
event.preventDefault();
event.stopPropagation();
const item = await Item.implementation.fromDropData(data);
if (!item) return;
// Call the data model's public method
await this.document.system.addAttachment(item);
}
static async #removeAttachment(event, target) {
// Call the data model's public method
await this.document.system.removeAttachment(target.dataset.uuid);
}
}
async _preparePartContext(partId, context) {
await super._preparePartContext(partId, context);
if (partId === 'attachments') {
// Keep this simple UI preparation in the mixin
const attachedUUIDs = this.document.system.attached;
@ -83,8 +78,8 @@ export default function ItemAttachmentSheet(Base) {
})
);
}
return context;
}
};
}
}

View file

@ -27,7 +27,7 @@ export default class ArmorSheet extends ItemAttachmentSheet(DHBaseItemSheet) {
template: 'systems/daggerheart/templates/sheets/items/armor/settings.hbs',
scrollable: ['.settings']
},
...super.PARTS,
...super.PARTS
};
/**@inheritdoc */

View file

@ -85,6 +85,16 @@ export default class ClassSheet extends DHBaseItemSheet {
await this.document.update({
'system.subclasses': [...this.document.system.subclasses.map(x => x.uuid), item.uuid]
});
} else if (item.type === 'feature') {
if (target.classList.contains('hope-feature')) {
await this.document.update({
'system.hopeFeatures': [...this.document.system.hopeFeatures.map(x => x.uuid), item.uuid]
});
} else if (target.classList.contains('class-feature')) {
await this.document.update({
'system.classFeatures': [...this.document.system.classFeatures.map(x => x.uuid), item.uuid]
});
}
} else if (item.type === 'weapon') {
if (target.classList.contains('primary-weapon-section')) {
if (!this.document.system.characterGuide.suggestedPrimaryWeapon && !item.system.secondary)
@ -144,7 +154,7 @@ export default class ClassSheet extends DHBaseItemSheet {
static async #removeItemFromCollection(_event, element) {
const { uuid, target } = element.dataset;
const prop = foundry.utils.getProperty(this.document.system, target);
await this.document.update({ [target]: prop.filter(i => i.uuid !== uuid) });
await this.document.update({ [`system.${target}`]: prop.filter(i => i.uuid !== uuid) });
}
/**

View file

@ -21,6 +21,7 @@ export default class FeatureSheet extends DHBaseItemSheet {
header: { template: 'systems/daggerheart/templates/sheets/items/feature/header.hbs' },
tabs: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs' },
description: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-description.hbs' },
settings: { template: 'systems/daggerheart/templates/sheets/items/feature/settings.hbs' },
actions: {
template: 'systems/daggerheart/templates/sheets/global/tabs/tab-actions.hbs',
scrollable: ['.actions']
@ -34,7 +35,7 @@ export default class FeatureSheet extends DHBaseItemSheet {
/**@override */
static TABS = {
primary: {
tabs: [{ id: 'description' }, { id: 'actions' }, { id: 'effects' }],
tabs: [{ id: 'description' }, { id: 'settings' }, { id: 'actions' }, { id: 'effects' }],
initial: 'description',
labelPrefix: 'DAGGERHEART.GENERAL.Tabs'
}
@ -67,7 +68,6 @@ export default class FeatureSheet extends DHBaseItemSheet {
}
),
title = game.i18n.localize('DAGGERHEART.CONFIG.SelectAction.selectType');
console.log(this.document);
return foundry.applications.api.DialogV2.prompt({
window: { title },

View file

@ -27,7 +27,7 @@ export default class WeaponSheet extends ItemAttachmentSheet(DHBaseItemSheet) {
template: 'systems/daggerheart/templates/sheets/items/weapon/settings.hbs',
scrollable: ['.settings']
},
...super.PARTS,
...super.PARTS
};
/**@inheritdoc */

View file

@ -8,4 +8,4 @@ export const encounterCountdown = {
position: 'countdown-encounter-position'
};
export const itemAttachmentSource = 'attachmentSource';
export const itemAttachmentSource = 'attachmentSource';

View file

@ -366,31 +366,6 @@ export const abilityCosts = {
label: 'Armor Stack',
group: 'TYPES.Actor.character'
},
prayer: {
id: 'prayer',
label: 'Prayer Dice',
group: 'TYPES.Actor.character'
},
favor: {
id: 'favor',
label: 'Favor Points',
group: 'TYPES.Actor.character'
},
slayer: {
id: 'slayer',
label: 'Slayer Dice',
group: 'TYPES.Actor.character'
},
tide: {
id: 'tide',
label: 'Tide',
group: 'TYPES.Actor.character'
},
chaos: {
id: 'chaos',
label: 'Chaos',
group: 'TYPES.Actor.character'
},
fear: {
id: 'fear',
label: 'Fear',

View file

@ -1334,3 +1334,14 @@ export const actionTypes = {
label: 'DAGGERHEART.CONFIG.ActionType.reaction'
}
};
export const itemResourceTypes = {
simple: {
id: 'simple',
label: 'DAGGERHEART.CONFIG.ItemResourceType.simple'
},
diceValue: {
id: 'diceValue',
label: 'DAGGERHEART.CONFIG.ItemResourceType.diceValue'
}
};

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',

View file

@ -56,7 +56,7 @@ export default class DHRoll extends Roll {
// Create Chat Message
if (config.source?.message) {
if(game.modules.get('dice-so-nice')?.active) await game.dice3d.showForRoll(roll, game.user, true);
if (game.modules.get('dice-so-nice')?.active) await game.dice3d.showForRoll(roll, game.user, true);
} else {
config.message = await this.toMessage(roll, config);
}

View file

@ -1,3 +1,5 @@
import { itemAbleRollParse } from '../helpers/utils.mjs';
export default class DhActiveEffect extends ActiveEffect {
get isSuppressed() {
// If this is a copied effect from an attachment, never suppress it
@ -24,16 +26,18 @@ export default class DhActiveEffect extends ActiveEffect {
*/
get isAttached() {
if (!this.parent || !this.parent.parent) return false;
// Check if this item's UUID is in any actor's armor or weapon attachment lists
const actor = this.parent.parent;
if (!actor || !actor.items) return false;
return actor.items.some(item => {
return (item.type === 'armor' || item.type === 'weapon') &&
item.system?.attached &&
Array.isArray(item.system.attached) &&
item.system.attached.includes(this.parent.uuid);
return (
(item.type === 'armor' || item.type === 'weapon') &&
item.system?.attached &&
Array.isArray(item.system.attached) &&
item.system.attached.includes(this.parent.uuid)
);
});
}
@ -51,11 +55,7 @@ export default class DhActiveEffect extends ActiveEffect {
}
static applyField(model, change, field) {
const isItemTarget = change.value.toLowerCase().startsWith('item.');
change.value = isItemTarget ? change.value.slice(5) : change.value;
change.value = Roll.safeEval(
Roll.replaceFormulaData(change.value, isItemTarget ? change.effect.parent : model)
);
change.value = itemAbleRollParse(change.value, model, change.effect.parent);
super.applyField(model, change, field);
}

View file

@ -396,7 +396,7 @@ export default class DhpActor extends Actor {
if (Hooks.call(`${CONFIG.DH.id}.preTakeDamage`, this, baseDamage, type) === false) return null;
if (this.type === 'companion') {
await this.modifyResource([{ value: 1, type: 'stress' }]);
await this.modifyResource([{ value: 1, key: 'stress' }]);
return;
}
@ -418,8 +418,8 @@ export default class DhpActor extends Actor {
const { modifiedDamage, armorSpent, stressSpent } = armorStackResult;
updates.find(u => u.type === 'hitPoints').value = modifiedDamage;
updates.push(
...(armorSpent ? [{ value: armorSpent, type: 'armorStack' }] : []),
...(stressSpent ? [{ value: stressSpent, type: 'stress' }] : [])
...(armorSpent ? [{ value: armorSpent, key: 'armorStack' }] : []),
...(stressSpent ? [{ value: stressSpent, key: 'stress' }] : [])
);
}
}
@ -434,8 +434,8 @@ export default class DhpActor extends Actor {
/* if(this.system.resistance[type]?.immunity) return 0;
if(this.system.resistance[type]?.resistance) baseDamage = Math.ceil(baseDamage / 2); */
if(this.canResist(type, 'immunity')) return 0;
if(this.canResist(type, 'resistance')) baseDamage = Math.ceil(baseDamage / 2);
if (this.canResist(type, 'immunity')) return 0;
if (this.canResist(type, 'resistance')) baseDamage = Math.ceil(baseDamage / 2);
// const flatReduction = this.system.resistance[type].reduction;
const flatReduction = this.getDamageTypeReduction(type);
@ -448,13 +448,16 @@ export default class DhpActor extends Actor {
}
canResist(type, resistance) {
if(!type) return 0;
if (!type) return 0;
return type.every(t => this.system.resistance[t]?.[resistance] === true);
}
getDamageTypeReduction(type) {
if(!type) return 0;
const reduction = Object.entries(this.system.resistance).reduce((a, [index, value]) => type.includes(index) ? Math.min(value.reduction, a) : a, Infinity);
if (!type) return 0;
const reduction = Object.entries(this.system.resistance).reduce(
(a, [index, value]) => (type.includes(index) ? Math.min(value.reduction, a) : a),
Infinity
);
return reduction === Infinity ? 0 : reduction;
}
@ -467,39 +470,61 @@ export default class DhpActor extends Actor {
if (!resources.length) return;
if (resources.find(r => r.type === 'stress')) this.convertStressDamageToHP(resources);
let updates = { actor: { target: this, resources: {} }, armor: { target: this.system.armor, resources: {} } };
let updates = {
actor: { target: this, resources: {} },
armor: { target: this.system.armor, resources: {} },
items: {}
};
resources.forEach(r => {
switch (r.type) {
case 'fear':
ui.resources.updateFear(
game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear) + r.value
);
break;
case 'armorStack':
updates.armor.resources['system.marks.value'] = Math.max(
Math.min(this.system.armor.system.marks.value + r.value, this.system.armorScore),
0
);
break;
default:
updates.actor.resources[`system.resources.${r.type}.value`] = Math.max(
Math.min(
this.system.resources[r.type].value + r.value,
this.system.resources[r.type].max
),
0
);
break;
if (r.keyIsID) {
updates.items[r.key] = {
target: r.target,
resources: {
'system.resource.value': r.target.system.resource.value + r.value
}
};
} else {
switch (r.key) {
case 'fear':
ui.resources.updateFear(
game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear) + r.value
);
break;
case 'armorStack':
updates.armor.resources['system.marks.value'] = Math.max(
Math.min(this.system.armor.system.marks.value + r.value, this.system.armorScore),
0
);
break;
default:
updates.actor.resources[`system.resources.${r.key}.value`] = Math.max(
Math.min(this.system.resources[r.key].value + r.value, this.system.resources[r.key].max),
0
);
break;
}
}
});
Object.values(updates).forEach(async u => {
if (Object.keys(u.resources).length > 0) {
await emitAsGM(
GMUpdateEvent.UpdateDocument,
u.target.update.bind(u.target),
u.resources,
u.target.uuid
);
Object.keys(updates).forEach(async key => {
const u = updates[key];
if (key === 'items') {
Object.values(u).forEach(async item => {
await emitAsGM(
GMUpdateEvent.UpdateDocument,
item.target.update.bind(item.target),
item.resources,
item.target.uuid
);
});
} else {
if (Object.keys(u.resources).length > 0) {
await emitAsGM(
GMUpdateEvent.UpdateDocument,
u.target.update.bind(u.target),
u.resources,
u.target.uuid
);
}
}
});
}

View file

@ -1,3 +1,5 @@
import { itemAbleRollParse } from './utils.mjs';
export default class RegisterHandlebarsHelpers {
static registerHelpers() {
Handlebars.registerHelper({
@ -6,8 +8,7 @@ export default class RegisterHandlebarsHelpers {
times: this.times,
damageFormula: this.damageFormula,
damageSymbols: this.damageSymbols,
tertiary: this.tertiary,
signedNumber: this.signedNumber
rollParsed: this.rollParsed
});
}
@ -43,7 +44,9 @@ export default class RegisterHandlebarsHelpers {
return new Handlebars.SafeString(Array.from(symbols).map(symbol => `<i class="fa-solid ${symbol}"></i>`));
}
static tertiary(a, b) {
return a ?? b;
static rollParsed(value, actor, item, numerical) {
const isNumerical = typeof numerical === 'boolean' ? numerical : false;
const result = itemAbleRollParse(value, actor, item);
return isNumerical && !result ? 0 : result;
}
}

View file

@ -245,10 +245,10 @@ export const getDamageLabel = damage => {
export const damageKeyToNumber = key => {
return {
'none': 0,
'minor': 1,
'major': 2,
'severe': 3
none: 0,
minor: 1,
major: 2,
severe: 3
}[key];
};
@ -299,3 +299,15 @@ export const updateActorTokens = async (actor, update) => {
}
}
};
export const itemAbleRollParse = (value, actor, item) => {
if (!value) return value;
const isItemTarget = value.toLowerCase().startsWith('item.');
const slicedValue = isItemTarget ? value.slice(5) : value;
try {
return Roll.safeEval(Roll.replaceFormulaData(slicedValue, isItemTarget ? item : actor));
} catch (_) {
return '';
}
};

View file

@ -5,6 +5,8 @@ export const preloadHandlebarsTemplates = async function () {
'systems/daggerheart/templates/sheets/global/partials/action-item.hbs',
'systems/daggerheart/templates/sheets/global/partials/domain-card-item.hbs',
'systems/daggerheart/templates/sheets/global/partials/inventory-fieldset-items.hbs',
'systems/daggerheart/templates/sheets/global/partials/item-resource.hbs',
'systems/daggerheart/templates/sheets/global/partials/resource-section.hbs',
'systems/daggerheart/templates/components/card-preview.hbs',
'systems/daggerheart/templates/levelup/parts/selectable-card-preview.hbs',
'systems/daggerheart/templates/sheets/global/partials/feature-section-item.hbs',